From 65fa5bda89bbc4f7c8a20f3c6e30cc6a9b648f00 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 24 Sep 2026 22:30:43 +0700 Subject: [PATCH] fix(mcp): run SQL Server scripts from MCP, AppleScript and the assistant as GO batches and return every result set --- CHANGELOG.md | 1 + .../AI/Chat/Tools/ExecuteQueryChatTool.swift | 12 +- .../QueryExecutionCoordinator+Batches.swift | 99 ++------ .../Access/DatabaseAccessBridge+Scripts.swift | 220 +++++++++++++++++ .../Access/DatabaseAccessBridge.swift | 67 +++--- TablePro/Core/MCP/MCPConnectionBridge.swift | 46 +++- .../MCP/Protocol/Tools/ExecuteQueryTool.swift | 18 +- .../MCP/Protocol/Tools/MCPToolSchema.swift | 57 +++-- .../Protocol/Tools/ToolQueryExecutor.swift | 55 ++++- .../Commands/ScriptRunQueryCommand.swift | 3 +- .../Core/Scripting/ScriptQueryRunner.swift | 22 +- .../Core/Scripting/ScriptResultEncoder.swift | 58 +++-- TablePro/Core/Scripting/ScriptingKeys.swift | 11 +- .../Execution/BatchResultMapping.swift | 12 + .../Execution/DatabaseDriver+Batches.swift | 26 ++ .../Services/Execution/ExecutableBatch.swift | 24 ++ .../Execution/ExternalStatementGate.swift | 11 + TablePro/Models/Query/QueryBatchResult.swift | 27 +++ TablePro/Resources/TablePro.sdef | 26 +- .../DatabaseAccessBridgeScriptTests.swift | 223 ++++++++++++++++++ .../ExternalStatementGateTests.swift | 25 ++ .../Core/MCP/MCPScriptResultTests.swift | 129 ++++++++++ .../Scripting/ScriptResultEncoderTests.swift | 56 ++++- .../Scripting/ScriptingDictionaryTests.swift | 24 ++ .../Execution/QueryBatchResultTests.swift | 55 +++++ .../Helpers/ScriptAnsweringDriver.swift | 147 ++++++++++++ docs/external-api/applescript.mdx | 24 ++ docs/external-api/mcp-tools.mdx | 10 +- 28 files changed, 1304 insertions(+), 184 deletions(-) create mode 100644 TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift create mode 100644 TablePro/Core/Services/Execution/DatabaseDriver+Batches.swift create mode 100644 TableProTests/Core/Database/DatabaseAccessBridgeScriptTests.swift create mode 100644 TableProTests/Core/MCP/MCPScriptResultTests.swift create mode 100644 TableProTests/Core/Services/Execution/QueryBatchResultTests.swift create mode 100644 TableProTests/Helpers/ScriptAnsweringDriver.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 68de66babc..17208a76dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -506,6 +506,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Previous run's column headings left over an empty grid under the error of a script that failed partway. - Row numbers of a longer previous result left beside the rows of a shorter one. - Run executing an old sorted query after a column header was clicked while a query ran. +- SQL Server scripts refused, or cut to their first result set, over MCP, AppleScript and the AI assistant. ### Security diff --git a/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift b/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift index bcd6b338f6..ace698dcb0 100644 --- a/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift @@ -9,8 +9,9 @@ struct ExecuteQueryChatTool: ChatTool { let name = "execute_query" let description = String(localized: """ Execute a SQL query against a connection. The connection's safe mode policy applies.\ - Multi-statement queries are rejected. Destructive operations (DROP, TRUNCATE, ALTER...DROP)\ - are blocked here; use confirm_destructive_operation instead. + Multi-statement queries are rejected, except on SQL Server, where a whole script runs batch by batch\ + at its GO lines and result_sets lists every result set when there is more than one. Destructive\ + operations (DROP, TRUNCATE, ALTER...DROP) are blocked here; use confirm_destructive_operation instead. """) let inputSchema: JsonValue = ChatToolSchemaBuilder.object( properties: [ @@ -53,7 +54,9 @@ struct ExecuteQueryChatTool: ChatTool { let meta = try await ToolConnectionMetadata.resolve(connectionId: connectionId) - guard !QueryClassifier.isMultiStatement(query, databaseType: meta.databaseType) else { + guard ExternalStatementGate.acceptsScripts(on: meta.databaseType) + || !QueryClassifier.isMultiStatement(query, databaseType: meta.databaseType) + else { return ChatToolResult( content: "Multi-statement queries are not supported. Send one statement at a time.", isError: true @@ -120,7 +123,8 @@ struct ExecuteQueryChatTool: ChatTool { scope: scope, maxRows: maxRows, timeoutSeconds: timeoutSeconds, - principal: .inAppAssistant + principal: .inAppAssistant, + unit: .script ) return ChatToolResult(content: payload.jsonString(prettyPrinted: true)) } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift index 4d73bb853b..5532a57c47 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift @@ -72,24 +72,17 @@ extension QueryExecutionCoordinator { /// The batches `text` runs as on this connection. `sourceOffset` moves them onto the tab's whole query when /// `text` is a selection or a single statement taken from it. func executionBatches(in text: String, sourceOffset: Int = 0) -> [ExecutableBatch] { - let grammar = parent.lexicalGrammar - let statements = QueryStatementScanner.executableStatements( - in: text, model: parent.statementModel, grammar: grammar - ) - let separators = parent.statementModel == .sql - ? SQLStatementScanner.batchSeparators(in: text, grammar: grammar) - : [] - return QueryBatchPlanner.batches( - in: text, statements: statements, separators: separators, sourceOffset: sourceOffset + QueryBatchPlanner.batches( + in: text, model: parent.statementModel, grammar: parent.lexicalGrammar, sourceOffset: sourceOffset ) } func executionRoute(for batches: [ExecutableBatch]) -> QueryExecutionRoute? { - let databaseType = parent.connection.type - let sendsBatchesWhole = DatabaseManager.shared.driver(for: parent.connectionId)?.supportsResultSetBatches ?? false - return QueryExecutionRoute.resolve(batches, sendsBatchesWhole: sendsBatchesWhole) { sql in - QueryExecutor.qualifiesForRowCap(sql: sql, tabType: .query, databaseType: databaseType) - } + QueryExecutionRoute.resolve( + batches, + databaseType: parent.connection.type, + sendsBatchesWhole: DatabaseManager.shared.driver(for: parent.connectionId)?.supportsResultSetBatches ?? false + ) } /// Runs each batch whole, in order, on one lease, and stops at the first that fails. @@ -284,17 +277,17 @@ extension QueryExecutionCoordinator { driver: DatabaseDriver, failureOutput: ServerOutputBox ) async throws -> BatchOutput { - var resultSets: [QueryResult] = [] - var rowsAffected = 0 - var errors: [PluginBatchError] = [] - var discarded = 0 - var executionTime: TimeInterval = 0 + var combined = QueryBatchResult.empty var printed = BatchPrintedOutput() for _ in 0.. QueryBatchResult { - if let answer = try await driver.executeBatch( - query: prepared.sentSQL, - rowCap: prepared.rowCap, - parameters: prepared.parameterValues - ) { - return answer - } - let single = try await driver.executeUserQuery( - query: prepared.sentSQL, - rowCap: prepared.rowCap, - parameters: prepared.parameterValues - ) - return QueryBatchResult( - resultSets: single.columns.isEmpty ? [] : [single], - rowsAffected: single.columns.isEmpty ? single.rowsAffected : 0, - errors: [], - discardedResultSetCount: 0, - executionTime: single.executionTime - ) - } - /// A failed batch ran, at least in part, and the server does not say how far, so every statement in it is /// treated as having run. That is the safe direction for the catalog: refreshing what did not change costs a /// fetch, missing what did leaves the sidebar wrong. @@ -564,8 +517,6 @@ extension QueryExecutionCoordinator { return resultSets } - /// What the run has to tell the reader beyond its results: a transaction the script left open, and result sets - /// the driver read past because a batch returned more than it keeps. private static func runNotice( outcome: BatchStatementOutcome, sessionState: PluginSessionTransactionState @@ -575,12 +526,10 @@ extension QueryExecutionCoordinator { case .completed(let results), .cancelled(let results), .failed(let results, _, _): outputs = results } - let discarded = outputs.reduce(0) { $0 + $1.discardedResultSetCount } - let discardedNote = discarded > 0 - ? String(format: String(localized: "%lld more result sets were not kept."), Int64(discarded)) - : nil - let notes = [sessionState.openTransactionNotice, discardedNote].compactMap { $0 } - return notes.isEmpty ? nil : notes.joined(separator: " ") + return BatchRunNotice.text( + discardedResultSetCount: outputs.reduce(0) { $0 + $1.discardedResultSetCount }, + sessionState: sessionState + ) } private func numberedResultSet(_ result: QueryResult, anchor: StatementAnchor?, index: Int) -> ResultSet { diff --git a/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift b/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift new file mode 100644 index 0000000000..91d447414a --- /dev/null +++ b/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift @@ -0,0 +1,220 @@ +// +// DatabaseAccessBridge+Scripts.swift +// TablePro +// + +import Foundation +import TableProPluginKit +import TableProSQLGrammar + +/// A text an external caller sends where one statement is not the unit: a SQL Server script. +/// +/// It takes the route the editor takes for the same text on the same connection. A lone plain query keeps the path +/// that bounds its fetch; anything else a driver that sends batches whole runs batch by batch, cut at `GO` lines, and +/// every result set comes back. The caller's gate has already been cleared for the whole text. +extension DatabaseAccessBridge { + internal struct ScriptOutcome: Sendable { + /// Every result the text returned, in order: a statement's own result, or each result set a script returned. + internal let resultSets: [QueryResult] + + /// What a caller that reads one result reads. For a statement that is its own result; for a script it is + /// the first result set, carrying the rows the whole script changed and what the run has to report. + internal let primary: QueryResult + + internal let executionTimeMs: Double + + internal var rowsReturned: Int { + resultSets.reduce(0) { $0 + $1.rows.count } + } + + internal static func statement(_ result: QueryResult, executionTimeMs: Double) -> ScriptOutcome { + ScriptOutcome(resultSets: [result], primary: result, executionTimeMs: executionTimeMs) + } + } + + internal func runScript( + scope: DatabaseScope, + query: String, + maxRows: Int, + timeoutSeconds: Int, + cancellation: (any StatementCancellationSignal)? + ) async throws -> ScriptOutcome { + guard !Self.statementText(query, grammar: .ansi).isEmpty else { + throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) + } + let (driver, databaseType) = try await resolveDriver(scope.connectionId) + let route = await MainActor.run { + QueryExecutionRoute.resolve( + QueryBatchPlanner.batches( + in: query, + model: QueryStatementModel.forDatabaseType(databaseType), + grammar: SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: scope.connectionId) + ), + databaseType: databaseType, + sendsBatchesWhole: driver.supportsResultSetBatches + ) + } + switch route { + case .single(let statement): + let outcome = try await runStatement( + scope: scope, + query: statement.sql, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds, + cancellation: cancellation + ) + return .statement(outcome.result, executionTimeMs: outcome.executionTimeMs) + case .batches(let batches): + return try await runBatches( + batches, + of: query, + scope: scope, + databaseType: databaseType, + rowCap: maxRows, + timeoutSeconds: timeoutSeconds, + cancellation: cancellation + ) + case .statements: + throw DatabaseAccessError.invalidArgument( + String(localized: "Update the database driver in Settings > Plugins to run several statements in one call.") + ) + case .needsBatchDriver: + throw DatabaseAccessError.invalidArgument( + String(localized: "Update the database driver in Settings > Plugins to run a batch more than once with GO.") + ) + case nil: + throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) + } + } + + /// Batch by batch on one lease, stopping at the first that raises an error, the way the editor runs them. Every + /// result set is held to `rowCap`, because a batch has no leading keyword to decide by. + private func runBatches( + _ batches: [ExecutableBatch], + of text: String, + scope: DatabaseScope, + databaseType: DatabaseType, + rowCap: Int, + timeoutSeconds: Int, + cancellation: (any StatementCancellationSignal)? + ) async throws -> ScriptOutcome { + let classification = QueryClassifier.classify(text, databaseType: databaseType) + let connectionId = scope.connectionId + let owner = DriverLeaseOwner() + let policy: DriverCancellationPolicy = classification.tier == .safe + ? .cancellableRead(owner) + : .protectedWrite + await forwardCancellation(cancellation, to: owner, on: connectionId) + + let route = await MainActor.run { DatabaseManager.shared.executionRoute(for: scope) } + let startLines = BatchErrorText.lines(of: batches.map(\.range.location), in: text) + let startTime = CFAbsoluteTimeGetCurrent() + let statementsRan = CatalogEvent.statementsRan( + connectionId: connectionId, + statements: batches.flatMap { $0.statements.map(\.sql) }, + databaseType: databaseType + ) + + let run: ScriptBatchRun + do { + run = try await runRacingTimeout( + scope: scope, + route: route, + policy: policy, + owner: owner, + timeoutSeconds: timeoutSeconds + ) { driver in + try await ScriptBatchRun.run(batches, startLines: startLines, rowCap: rowCap, driver: driver) + } + } catch { + if classification.tier != .safe { + CatalogChangeService.post(statementsRan) + } + throw error + } + + CatalogChangeService.post(statementsRan) + return run.outcome(executionTimeMs: (CFAbsoluteTimeGetCurrent() - startTime) * 1_000) + } +} + +/// What a script's batches answered with, and what the session held once they had all run. +struct ScriptBatchRun: Sendable { + let answers: [QueryBatchResult] + let sessionState: PluginSessionTransactionState + + /// The session is asked what it holds before and after, as the editor asks: the first answer decides how a + /// failure reads, and the second whether the script left a transaction open. A batch that raised an error ends the + /// run as a failure, because a caller reading only the rows would otherwise take a failed write for a done one. + static func run( + _ batches: [ExecutableBatch], + startLines: [Int], + rowCap: Int, + driver: DatabaseDriver + ) async throws -> ScriptBatchRun { + let plan = BatchTransactionPlan.autocommit.joining(await driver.heldSessionTransactionState()) + var answers: [QueryBatchResult] = [] + for (batch, startLine) in zip(batches, startLines) { + try Task.checkCancellation() + let answer = try await repeatedAnswer(to: batch, rowCap: rowCap, driver: driver) + answers.append(answer) + guard answer.errors.isEmpty else { + let context = MultiStatementFailureContext( + failure: .batch(sql: batch.sql), + errorDescription: BatchErrorText.describe(answer.errors, batchStartLine: startLine) ?? "", + executedCount: answers.count, + totalCount: batches.count, + plan: plan, + sessionState: await driver.heldSessionTransactionState(), + unit: .batch + ) + throw DatabaseError.queryFailed(context.report().message) + } + } + return ScriptBatchRun(answers: answers, sessionState: await driver.heldSessionTransactionState()) + } + + /// `GO 5` sends the batch five times and keeps every answer. A repetition that raised an error ends the + /// repeating, as it ends the run. + private static func repeatedAnswer( + to batch: ExecutableBatch, + rowCap: Int, + driver: DatabaseDriver + ) async throws -> QueryBatchResult { + var combined = QueryBatchResult.empty + for _ in 0.. DatabaseAccessBridge.ScriptOutcome { + let resultSets = answers.flatMap(\.resultSets) + let first = resultSets.first + var primary = QueryResult( + columns: first?.columns ?? [], + columnTypes: first?.columnTypes ?? [], + rows: first?.rows ?? [], + rowsAffected: answers.reduce(0) { $0 + $1.rowsAffected }, + executionTime: executionTimeMs / 1_000, + error: nil + ) + primary.isTruncated = first?.isTruncated ?? false + let notes = [ + first?.statusMessage, + BatchRunNotice.text( + discardedResultSetCount: answers.reduce(0) { $0 + $1.discardedResultSetCount }, + sessionState: sessionState + ) + ].compactMap { $0 } + primary.statusMessage = notes.isEmpty ? nil : notes.joined(separator: " ") + return DatabaseAccessBridge.ScriptOutcome( + resultSets: resultSets, + primary: primary, + executionTimeMs: executionTimeMs + ) + } +} diff --git a/TablePro/Core/Database/Access/DatabaseAccessBridge.swift b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift index 431d6d78ea..c262b16b59 100644 --- a/TablePro/Core/Database/Access/DatabaseAccessBridge.swift +++ b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -206,15 +206,7 @@ internal actor DatabaseAccessBridge { ? .cancellableRead(owner) : .protectedWrite - if let cancellation { - await cancellation.onCancelRequested { - await MainActor.run { - try? DatabaseManager.shared.cancelRunningQuery( - owner: owner, on: connectionId, delivery: .immediate - ) - } - } - } + await forwardCancellation(cancellation, to: owner, on: connectionId) let route = await MainActor.run { DatabaseManager.shared.executionRoute(for: scope) } let startTime = CFAbsoluteTimeGetCurrent() @@ -232,12 +224,17 @@ internal actor DatabaseAccessBridge { route: route, policy: policy, owner: owner, - statement: statement, - shouldCap: shouldCap, - maxRows: maxRows, - normalizedQuery: normalizedQuery, timeoutSeconds: timeoutSeconds - ) + ) { driver in + if shouldCap { + return try await driver.executeUserQuery( + query: statement.sql, + rowCap: statement.rowCap ?? maxRows, + parameters: nil + ) + } + return try await driver.execute(query: normalizedQuery) + } } catch { if classification.tier != .safe { CatalogChangeService.post(statementRan) @@ -249,34 +246,38 @@ internal actor DatabaseAccessBridge { return StatementOutcome(result: result, executionTimeMs: (CFAbsoluteTimeGetCurrent() - startTime) * 1_000) } - private func runRacingTimeout( + internal func forwardCancellation( + _ cancellation: (any StatementCancellationSignal)?, + to owner: DriverLeaseOwner, + on connectionId: UUID + ) async { + guard let cancellation else { return } + await cancellation.onCancelRequested { + await MainActor.run { + try? DatabaseManager.shared.cancelRunningQuery( + owner: owner, on: connectionId, delivery: .immediate + ) + } + } + } + + internal func runRacingTimeout( scope: DatabaseScope, route: ScopedDriverRoute, policy: DriverCancellationPolicy, owner: DriverLeaseOwner, - statement: LeadingRowsStatement, - shouldCap: Bool, - maxRows: Int, - normalizedQuery: String, - timeoutSeconds: Int - ) async throws -> QueryResult { + timeoutSeconds: Int, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> Output + ) async throws -> Output { let connectionId = scope.connectionId - return try await withThrowingTaskGroup(of: QueryResult.self) { group in + return try await withThrowingTaskGroup(of: Output.self) { group in group.addTask { try await DatabaseManager.shared.withScopedDriver( scope: scope, route: route, - cancellation: policy - ) { driver in - if shouldCap { - return try await driver.executeUserQuery( - query: statement.sql, - rowCap: statement.rowCap ?? maxRows, - parameters: nil - ) - } - return try await driver.execute(query: normalizedQuery) - } + cancellation: policy, + body + ) } group.addTask { try await Task.sleep(for: .seconds(timeoutSeconds)) diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index 47bdbc449f..e99986bf34 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -159,6 +159,23 @@ public actor MCPConnectionBridge { ) } + func executeScript( + scope: DatabaseScope, + query: String, + maxRows: Int, + timeoutSeconds: Int, + cancellation: MCPCancellationToken? + ) async throws -> (payload: JsonValue, rowsReturned: Int) { + let outcome = try await access.runScript( + scope: scope, + query: query, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds, + cancellation: cancellation + ) + return (Self.encode(script: outcome, scope: scope), outcome.rowsReturned) + } + func runStatement( scope: DatabaseScope, query: String, @@ -177,6 +194,33 @@ public actor MCPConnectionBridge { } static func encode(result: QueryResult, scope: DatabaseScope, executionTimeMs: Double) -> JsonValue { + .object(fields(of: result, scope: scope, executionTimeMs: executionTimeMs)) + } + + /// A script's answer in the shape a statement's has, so a client that reads one result reads the first, plus + /// `result_sets` with every one of them once there is more than one to read. + static func encode(script: DatabaseAccessBridge.ScriptOutcome, scope: DatabaseScope) -> JsonValue { + var response = fields(of: script.primary, scope: scope, executionTimeMs: script.executionTimeMs) + if script.resultSets.count > 1 { + response["result_sets"] = .array(script.resultSets.map(encode(resultSet:))) + } + return .object(response) + } + + static func encode(resultSet: QueryResult) -> JsonValue { + var entry: [String: JsonValue] = [ + "columns": .array(resultSet.columns.map { .string($0) }), + "rows": .array(resultSet.rows.map { row in .array(row.map(cellValue)) }), + "row_count": .int(resultSet.rows.count), + "is_truncated": .bool(resultSet.isTruncated) + ] + if let statusMessage = resultSet.statusMessage { + entry["status_message"] = .string(statusMessage) + } + return .object(entry) + } + + private static func fields(of result: QueryResult, scope: DatabaseScope, executionTimeMs: Double) -> [String: JsonValue] { var response: [String: JsonValue] = [ "columns": .array(result.columns.map { .string($0) }), "rows": .array(result.rows.map { row in .array(row.map(cellValue)) }), @@ -192,7 +236,7 @@ public actor MCPConnectionBridge { if let statusMessage = result.statusMessage { response["status_message"] = .string(statusMessage) } - return .object(response) + return response } static func cellValue(_ cell: PluginCellValue) -> JsonValue { diff --git a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift index 4634002999..7fc99d44d7 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift @@ -6,10 +6,12 @@ public struct ExecuteQueryTool: MCPToolImplementation { public static let title: String? = String(localized: "Execute Query") public static let description = String( localized: """ - Run one statement. Reads need tools:read; anything that writes needs tools:write and is subject \ - to the connection's Safe Mode, which asks the user to approve it. DROP and TRUNCATE go through \ - confirm_destructive_operation instead. Statements that read or write files, or run server-side \ - code, are refused. Send one statement per call. + Run one statement, or on SQL Server a whole script. Reads need tools:read; anything that writes \ + needs tools:write and is subject to the connection's Safe Mode, which asks the user to approve it. \ + DROP and TRUNCATE go through confirm_destructive_operation instead. Statements that read or write \ + files, or run server-side code, are refused. Send one statement per call, except on SQL Server, where \ + a script runs batch by batch at its GO lines and result_sets lists every result set when there is \ + more than one. """ ) public static let requiredScopes: Set = [.toolsRead] @@ -26,7 +28,7 @@ public struct ExecuteQueryTool: MCPToolImplementation { public static let inputSchema = MCPToolSchema.object( properties: [ "connection_id": MCPToolSchema.connectionId, - "query": MCPToolSchema.string(String(localized: "One SQL or NoSQL statement")), + "query": MCPToolSchema.string(String(localized: "One SQL or NoSQL statement, or a SQL Server script")), "max_rows": MCPToolSchema.integer( String(localized: "Maximum rows to return. Defaults to the server's configured row limit."), minimum: 1 @@ -41,7 +43,7 @@ public struct ExecuteQueryTool: MCPToolImplementation { required: ["connection_id", "query"] ) - public static let outputSchema: JsonValue? = MCPToolSchema.resultSet + public static let outputSchema: JsonValue? = MCPToolSchema.scriptResult private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") @@ -77,6 +79,7 @@ public struct ExecuteQueryTool: MCPToolImplementation { sql: query, meta: meta, allowsDestructive: false, + allowsMultiStatement: ExternalStatementGate.acceptsScripts(on: meta.databaseType), operationLabel: String(localized: "a query"), context: context, services: services @@ -100,7 +103,8 @@ public struct ExecuteQueryTool: MCPToolImplementation { maxRows: maxRows, timeoutSeconds: timeoutSeconds, context: context, - secrets: meta.redactionSecrets + secrets: meta.redactionSecrets, + unit: .script ) await context.progress.emit(progress: 1.0, total: 1.0, message: "Done") diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolSchema.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolSchema.swift index 9aa43dc52a..9c5c2dd61b 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPToolSchema.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolSchema.swift @@ -84,25 +84,52 @@ enum MCPToolSchema { "description": .string(String(localized: "Cell value: string, number, boolean, or null")) ]) + private static let resultSetProperties: [String: JsonValue] = [ + "columns": array(String(localized: "Column names in result order"), of: stringItem), + "rows": array( + String(localized: "Rows, each an array aligned with columns"), + of: .object(["type": .string("array"), "items": cell]) + ), + "row_count": integer(String(localized: "Number of rows returned")), + "rows_affected": integer(String(localized: "Rows the statement changed")), + "execution_time_ms": number(String(localized: "Server round trip in milliseconds")), + "is_truncated": boolean(String(localized: "Whether the row limit clipped the result")), + "status_message": string(String(localized: "Driver status message, when the engine sent one")), + "database": string(String(localized: "Database the statement ran against")), + "schema": string(String(localized: "Schema the statement ran against")) + ] + + private static let resultSetRequired = [ + "columns", "rows", "row_count", "rows_affected", "execution_time_ms", "is_truncated" + ] + static let resultSet: JsonValue = object( - properties: [ - "columns": array(String(localized: "Column names in result order"), of: stringItem), - "rows": array( - String(localized: "Rows, each an array aligned with columns"), - of: .object(["type": .string("array"), "items": cell]) - ), - "row_count": integer(String(localized: "Number of rows returned")), - "rows_affected": integer(String(localized: "Rows the statement changed")), - "execution_time_ms": number(String(localized: "Server round trip in milliseconds")), - "is_truncated": boolean(String(localized: "Whether the row limit clipped the result")), - "status_message": string(String(localized: "Driver status message, when the engine sent one")), - "database": string(String(localized: "Database the statement ran against")), - "schema": string(String(localized: "Schema the statement ran against")) - ], - required: ["columns", "rows", "row_count", "rows_affected", "execution_time_ms", "is_truncated"], + properties: resultSetProperties, + required: resultSetRequired, allowsAdditional: true ) + /// A result set, where the top-level fields describe the first one a SQL Server script returned and + /// `result_sets` lists every one of them once there is more than one. + static let scriptResult: JsonValue = object( + properties: resultSetProperties.merging([ + "result_sets": array( + String(localized: "Every result set a script returned, in order, when it returned more than one"), + of: object( + properties: resultSetProperties.filter { scriptResultSetKeys.contains($0.key) }, + required: ["columns", "rows", "row_count", "is_truncated"], + allowsAdditional: true + ) + ) + ]) { current, _ in current }, + required: resultSetRequired, + allowsAdditional: true + ) + + private static let scriptResultSetKeys: Set = [ + "columns", "rows", "row_count", "is_truncated", "status_message" + ] + static let columnDefinition: JsonValue = object( properties: [ "name": string(String(localized: "Column name")), diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift index 9ae87941fd..c4b63476ff 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift @@ -1,6 +1,13 @@ import Foundation enum ToolQueryExecutor { + /// What one call sends: a statement, or a text that runs as a script where the engine takes one, whose every + /// result set comes back. + enum Unit: Sendable { + case statement + case script + } + static func executeAndLog( services: MCPToolServices, query: String, @@ -8,7 +15,8 @@ enum ToolQueryExecutor { maxRows: Int, timeoutSeconds: Int, context: MCPRequestContext, - secrets: [String] + secrets: [String], + unit: Unit = .statement ) async throws -> JsonValue { try await context.cancellation.throwIfCancelled() return try await executeAndLog( @@ -19,7 +27,8 @@ enum ToolQueryExecutor { timeoutSeconds: timeoutSeconds, principal: context.principal, cancellation: context.cancellation, - secrets: secrets + secrets: secrets, + unit: unit ) } @@ -31,22 +40,24 @@ enum ToolQueryExecutor { timeoutSeconds: Int, principal: MCPPrincipal, cancellation: MCPCancellationToken? = nil, - secrets: [String] = [] + secrets: [String] = [], + unit: Unit = .statement ) async throws -> JsonValue { let connectionId = scope.connectionId let databaseName = scope.database let startTime = Date() let operationStart = ContinuousClock.Instant.now do { - let result = try await services.connectionBridge.executeQuery( - scope: scope, + let (result, rowCount) = try await run( + unit, + services: services, query: query, + scope: scope, maxRows: maxRows, timeoutSeconds: timeoutSeconds, cancellation: cancellation ) let elapsed = Date().timeIntervalSince(startTime) - let rowCount = result["row_count"]?.intValue ?? 0 await services.authPolicy.logQuery( sql: query, connectionId: connectionId, @@ -104,6 +115,38 @@ enum ToolQueryExecutor { } } + /// The call's payload, and the rows it returned across every result set, which the history and the audit log + /// record. + private static func run( + _ unit: Unit, + services: MCPToolServices, + query: String, + scope: DatabaseScope, + maxRows: Int, + timeoutSeconds: Int, + cancellation: MCPCancellationToken? + ) async throws -> (payload: JsonValue, rowsReturned: Int) { + switch unit { + case .statement: + let payload = try await services.connectionBridge.executeQuery( + scope: scope, + query: query, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds, + cancellation: cancellation + ) + return (payload, payload["row_count"]?.intValue ?? 0) + case .script: + return try await services.connectionBridge.executeScript( + scope: scope, + query: query, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds, + cancellation: cancellation + ) + } + } + static func translate(_ error: Error, secrets: [String]) -> Error { if let dataError = error as? DatabaseAccessError { return MCPToolExecutionError.from(dataError, secrets: secrets) diff --git a/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift b/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift index dc9a328143..d1c5ff4313 100644 --- a/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift +++ b/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift @@ -28,7 +28,6 @@ internal final class ScriptRunQueryCommand: ScriptCommand { client: sendingApplication ) - let outcome = try await ScriptQueryRunner.run(request, bridge: bridge) - return ScriptResultEncoder.encode(outcome.result, executionTimeMs: outcome.executionTimeMs) + return ScriptResultEncoder.encode(try await ScriptQueryRunner.run(request, bridge: bridge)) } } diff --git a/TablePro/Core/Scripting/ScriptQueryRunner.swift b/TablePro/Core/Scripting/ScriptQueryRunner.swift index e3ccd070ea..6a37f80b74 100644 --- a/TablePro/Core/Scripting/ScriptQueryRunner.swift +++ b/TablePro/Core/Scripting/ScriptQueryRunner.swift @@ -5,7 +5,7 @@ import Foundation -/// Runs one statement for a script, with every gate a script has to clear. +/// 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 @@ -34,16 +34,11 @@ internal enum ScriptQueryRunner { internal let client: String? } - internal struct Outcome: Sendable { - internal let result: QueryResult - internal let executionTimeMs: Double - } - internal static func run( _ request: Request, bridge: DatabaseAccessBridge, history: QueryHistoryRecording = QueryHistoryManager.shared - ) async throws -> Outcome { + ) 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()`. @@ -64,7 +59,8 @@ internal enum ScriptQueryRunner { databaseType: snapshot.databaseType, externalAccess: snapshot.externalAccess, loadsExtensions: snapshot.loadsExtensions, - allowsDestructive: true + allowsDestructive: true, + allowsMultiStatement: ExternalStatementGate.acceptsScripts(on: snapshot.databaseType) ) ) @@ -94,7 +90,7 @@ internal enum ScriptQueryRunner { let started = Date() do { - let outcome = try await bridge.runStatement( + let outcome = try await bridge.runScript( scope: scope, query: request.sql, maxRows: rowLimit, @@ -106,22 +102,22 @@ internal enum ScriptQueryRunner { scope: scope, databaseType: snapshot.databaseType, elapsed: Date().timeIntervalSince(started), - rowCount: outcome.result.rows.count, + rowCount: outcome.rowsReturned, error: nil, history: history ) await report( .succeeded( OperationSummary( - rowsReturned: outcome.result.rows.count, - rowsAffected: outcome.result.rowsAffected + rowsReturned: outcome.rowsReturned, + rowsAffected: outcome.primary.rowsAffected ) ), request: request, scope: scope, startedAt: startedAt ) - return Outcome(result: outcome.result, executionTimeMs: outcome.executionTimeMs) + return outcome } catch { let message = ScriptingError.from(error, secrets: snapshot.redactionSecrets).errorDescription await record( diff --git a/TablePro/Core/Scripting/ScriptResultEncoder.swift b/TablePro/Core/Scripting/ScriptResultEncoder.swift index f7164b4449..8b83351eaa 100644 --- a/TablePro/Core/Scripting/ScriptResultEncoder.swift +++ b/TablePro/Core/Scripting/ScriptResultEncoder.swift @@ -45,21 +45,23 @@ internal enum ScriptResultEncoder { rowsAffected: metadata.rowsAffected, truncated: metadata.truncated, executionTimeMs: metadata.executionTimeMs, - statusMessage: metadata.statusMessage + statusMessage: metadata.statusMessage, + results: [] ) } - internal static func encode( - _ result: QueryResult, - executionTimeMs: Double - ) -> [String: Any] { - record( - columns: result.columns, - rows: result.rows, - rowsAffected: result.rowsAffected, - truncated: result.isTruncated, - executionTimeMs: executionTimeMs, - statusMessage: result.statusMessage + /// What `run query` answered. The record itself is the first result, as it always was; `results` lists every + /// result set once a SQL Server script returned more than one, and is empty otherwise. + internal static func encode(_ outcome: DatabaseAccessBridge.ScriptOutcome) -> [String: Any] { + let primary = outcome.primary + return record( + columns: primary.columns, + rows: primary.rows, + rowsAffected: primary.rowsAffected, + truncated: primary.isTruncated, + executionTimeMs: outcome.executionTimeMs, + statusMessage: primary.statusMessage, + results: outcome.resultSets.count > 1 ? outcome.resultSets.map(resultSet(of:)) : [] ) } @@ -70,7 +72,8 @@ internal enum ScriptResultEncoder { rowsAffected: 0, truncated: false, executionTimeMs: 0, - statusMessage: nil + statusMessage: nil, + results: [] ) } @@ -80,20 +83,33 @@ internal enum ScriptResultEncoder { rowsAffected: Int, truncated: Bool, executionTimeMs: Double, - statusMessage: String? + statusMessage: String?, + results: [[String: Any]] ) -> [String: Any] { - var fields: [String: Any] = [ + [ ScriptingKeys.QueryResult.columns: columns, - ScriptingKeys.QueryResult.rows: rows.map { row in - [ScriptingKeys.ResultRow.values: row.map(text(of:))] - }, + ScriptingKeys.QueryResult.rows: rowRecords(rows), ScriptingKeys.QueryResult.rowCount: rows.count, ScriptingKeys.QueryResult.rowsAffected: rowsAffected, ScriptingKeys.QueryResult.truncated: truncated, - ScriptingKeys.QueryResult.executionTime: executionTimeMs + ScriptingKeys.QueryResult.executionTime: executionTimeMs, + ScriptingKeys.QueryResult.statusMessage: statusMessage ?? "", + ScriptingKeys.QueryResult.results: results + ] + } + + private static func resultSet(of result: QueryResult) -> [String: Any] { + [ + ScriptingKeys.QueryResult.columns: result.columns, + ScriptingKeys.QueryResult.rows: rowRecords(result.rows), + ScriptingKeys.QueryResult.rowCount: result.rows.count, + ScriptingKeys.QueryResult.truncated: result.isTruncated, + ScriptingKeys.QueryResult.statusMessage: result.statusMessage ?? "" ] - fields[ScriptingKeys.QueryResult.statusMessage] = statusMessage ?? "" - return fields + } + + private static func rowRecords(_ rows: [[PluginCellValue]]) -> [[String: Any]] { + rows.map { row in [ScriptingKeys.ResultRow.values: row.map(text(of:))] } } internal static func text(of cell: PluginCellValue) -> String { diff --git a/TablePro/Core/Scripting/ScriptingKeys.swift b/TablePro/Core/Scripting/ScriptingKeys.swift index 37206362ff..1f1e551dad 100644 --- a/TablePro/Core/Scripting/ScriptingKeys.swift +++ b/TablePro/Core/Scripting/ScriptingKeys.swift @@ -21,9 +21,18 @@ internal enum ScriptingKeys { internal static let truncated = "scriptTruncated" internal static let executionTime = "scriptExecutionTime" internal static let statusMessage = "scriptStatusMessage" + internal static let results = "scriptResults" internal static let all = [ - columns, rows, rowCount, rowsAffected, truncated, executionTime, statusMessage + columns, rows, rowCount, rowsAffected, truncated, executionTime, statusMessage, results + ] + } + + /// One entry of a query result's `results`. It shares its keys with `query result`, because the dictionary + /// declares these properties under one name and one code in both record types. + internal enum ResultSet { + internal static let all = [ + QueryResult.columns, QueryResult.rows, QueryResult.rowCount, QueryResult.truncated, QueryResult.statusMessage ] } diff --git a/TablePro/Core/Services/Execution/BatchResultMapping.swift b/TablePro/Core/Services/Execution/BatchResultMapping.swift index 1a272e6b01..a41ec0bb4d 100644 --- a/TablePro/Core/Services/Execution/BatchResultMapping.swift +++ b/TablePro/Core/Services/Execution/BatchResultMapping.swift @@ -49,6 +49,18 @@ enum BatchResultMapping { } } +/// What a run of batches has to tell the reader beyond its results: a transaction the script left open, and result +/// sets the driver read past because a batch returned more than it keeps. +enum BatchRunNotice { + static func text(discardedResultSetCount: Int, sessionState: PluginSessionTransactionState) -> String? { + let discardedNote = discardedResultSetCount > 0 + ? String(format: String(localized: "%lld more result sets were not kept."), Int64(discardedResultSetCount)) + : nil + let notes = [sessionState.openTransactionNotice, discardedNote].compactMap { $0 } + return notes.isEmpty ? nil : notes.joined(separator: " ") + } +} + /// How an error a batch raised reads in the editor. /// /// The server numbers lines from the first line of the text it was sent, so a batch's line is moved onto the editor's diff --git a/TablePro/Core/Services/Execution/DatabaseDriver+Batches.swift b/TablePro/Core/Services/Execution/DatabaseDriver+Batches.swift new file mode 100644 index 0000000000..75a14d2ea3 --- /dev/null +++ b/TablePro/Core/Services/Execution/DatabaseDriver+Batches.swift @@ -0,0 +1,26 @@ +// +// DatabaseDriver+Batches.swift +// TablePro +// + +import Foundation + +extension DatabaseDriver { + /// `query` sent as one batch and read to its end. + /// + /// A driver that declared batches and then declines one gets the text as a single statement, which is what it + /// was sent as before batches existed. + func answerBatch(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryBatchResult { + if let answer = try await executeBatch(query: query, rowCap: rowCap, parameters: parameters) { + return answer + } + let single = try await executeUserQuery(query: query, rowCap: rowCap, parameters: parameters) + return QueryBatchResult( + resultSets: single.columns.isEmpty ? [] : [single], + rowsAffected: single.columns.isEmpty ? single.rowsAffected : 0, + errors: [], + discardedResultSetCount: 0, + executionTime: single.executionTime + ) + } +} diff --git a/TablePro/Core/Services/Execution/ExecutableBatch.swift b/TablePro/Core/Services/Execution/ExecutableBatch.swift index cd77c400ce..e0379ebc8b 100644 --- a/TablePro/Core/Services/Execution/ExecutableBatch.swift +++ b/TablePro/Core/Services/Execution/ExecutableBatch.swift @@ -43,6 +43,18 @@ struct ExecutableBatch: Sendable { /// Groups a scanned text's statements into the batches its engine runs. enum QueryBatchPlanner { + /// The batches `text` runs as, scanned the way its engine reads it. Only a SQL text can hold a `GO` line. + static func batches( + in text: String, + model: QueryStatementModel, + grammar: SQLLexicalGrammar, + sourceOffset: Int = 0 + ) -> [ExecutableBatch] { + let statements = QueryStatementScanner.executableStatements(in: text, model: model, grammar: grammar) + let separators = model == .sql ? SQLStatementScanner.batchSeparators(in: text, grammar: grammar) : [] + return batches(in: text, statements: statements, separators: separators, sourceOffset: sourceOffset) + } + /// `statements` and `separators` are in `text`'s coordinates; the batches come back shifted by `sourceOffset` /// onto the tab's whole query, the way a run started from a selection already shifts its statements. static func batches( @@ -115,4 +127,16 @@ enum QueryExecutionRoute { } return .batches(batches) } + + /// The route on an engine of `databaseType`, where a plain query is one the editor caps and pages. + @MainActor + static func resolve( + _ batches: [ExecutableBatch], + databaseType: DatabaseType, + sendsBatchesWhole: Bool + ) -> QueryExecutionRoute? { + resolve(batches, sendsBatchesWhole: sendsBatchesWhole) { sql in + QueryExecutor.qualifiesForRowCap(sql: sql, tabType: .query, databaseType: databaseType) + } + } } diff --git a/TablePro/Core/Services/Execution/ExternalStatementGate.swift b/TablePro/Core/Services/Execution/ExternalStatementGate.swift index 0fbc175d82..842af4cacd 100644 --- a/TablePro/Core/Services/Execution/ExternalStatementGate.swift +++ b/TablePro/Core/Services/Execution/ExternalStatementGate.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProSQLGrammar internal enum ExternalStatementGateError: LocalizedError, Equatable { case denied(String) @@ -108,6 +109,16 @@ internal enum ExternalStatementGate { return classification } + /// 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 + /// request carries as one batch whether or not its statements end in `;`, and a local variable exists only inside + /// the batch that declares it, so one statement per call is neither enforceable nor useful. Every other engine + /// keeps one statement per call. + internal static func acceptsScripts(on databaseType: DatabaseType) -> Bool { + databaseType.lexicalGrammar.contains(.batchSeparatorLines) + } + /// A loaded SQLite extension can add functions that write files or run a nested statement, and /// the classifier reads `SELECT BlobToFile(...)` as a read. So on a connection that loads /// extensions, a statement from outside the app may call only what SQLite itself provides. Nil diff --git a/TablePro/Models/Query/QueryBatchResult.swift b/TablePro/Models/Query/QueryBatchResult.swift index 8285d7a908..df45727465 100644 --- a/TablePro/Models/Query/QueryBatchResult.swift +++ b/TablePro/Models/Query/QueryBatchResult.swift @@ -22,4 +22,31 @@ struct QueryBatchResult { discardedResultSetCount: 0, executionTime: 0 ) + + /// The result sets one batch keeps across all of its repetitions, the same ceiling the driver keeps for one. + static let keptResultSetLimit = 100 + + /// This answer and `next`, read after it on the same batch, as one answer. `GO 5` sends a batch five times and + /// keeps what every repetition returned, up to the ceiling one request keeps. An error keeps its place among the + /// result sets, counted across the repetitions. + func followed(by next: QueryBatchResult) -> QueryBatchResult { + let room = max(Self.keptResultSetLimit - resultSets.count, 0) + let nextErrors = next.errors.map { error in + PluginBatchError( + message: error.message, + code: error.code, + line: error.line, + procedure: error.procedure, + precedingResultSetCount: min(resultSets.count + error.precedingResultSetCount, Self.keptResultSetLimit) + ) + } + return QueryBatchResult( + resultSets: resultSets + next.resultSets.prefix(room), + rowsAffected: rowsAffected + next.rowsAffected, + errors: errors + nextErrors, + discardedResultSetCount: discardedResultSetCount + next.discardedResultSetCount + + max(next.resultSets.count - room, 0), + executionTime: executionTime + next.executionTime + ) + } } diff --git a/TablePro/Resources/TablePro.sdef b/TablePro/Resources/TablePro.sdef index 7af1f3b144..f297869f3b 100644 --- a/TablePro/Resources/TablePro.sdef +++ b/TablePro/Resources/TablePro.sdef @@ -41,6 +41,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -65,6 +85,10 @@ + + + + @@ -174,7 +198,7 @@ - + diff --git a/TableProTests/Core/Database/DatabaseAccessBridgeScriptTests.swift b/TableProTests/Core/Database/DatabaseAccessBridgeScriptTests.swift new file mode 100644 index 0000000000..6b6d12a5d8 --- /dev/null +++ b/TableProTests/Core/Database/DatabaseAccessBridgeScriptTests.swift @@ -0,0 +1,223 @@ +// +// DatabaseAccessBridgeScriptTests.swift +// TableProTests +// +// MCP, the assistant and AppleScript send SQL Server text through the same bridge. Before it learned batches the +// bridge sent a script through the one-result call, so everything after the first result set was dropped (#3078). +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL Server scripts sent from outside the app", .serialized) +@MainActor +struct DatabaseAccessBridgeScriptTests { + private static let reporterScript = """ + DECLARE @sn NVARCHAR(50) = '2404GQV000066A00105'; + + SELECT * + FROM serialnew + WHERE [S/N] = @sn; + + SELECT * + FROM [v_wms_joined] + WHERE [S/N] = @sn; + + SELECT * + FROM drm_report_n + WHERE [Serial number] = @sn; + + SELECT * + FROM serial_existed + WHERE sn_code = @sn; + """ + + nonisolated private static func fourResultSets(_ query: String) -> QueryBatchResult { + ScriptAnsweringDriver.batch([ + ScriptAnsweringDriver.resultSet(columns: ["S/N", "model"], rows: [["2404GQV000066A00105", "X1"]]), + ScriptAnsweringDriver.resultSet(columns: ["S/N", "warehouse"], rows: [["2404GQV000066A00105", "W2"]]), + ScriptAnsweringDriver.resultSet(columns: ["Serial number"], rows: []), + ScriptAnsweringDriver.resultSet(columns: ["sn_code", "seen_at"], rows: [["a", "b"], ["c", "d"]]) + ]) + } + + private func install(_ driver: ScriptAnsweringDriver) -> DatabaseScope { + var session = ConnectionSession(connection: driver.connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: driver.connection.id) + return DatabaseScope(connectionId: driver.connection.id, database: driver.connection.database, schema: nil) + } + + private func makeDriver( + sendsBatchesWhole: Bool = true, + transactionState: PluginSessionTransactionState = .idle, + answer: @escaping @Sendable (String) -> QueryBatchResult = { _ in .empty } + ) -> ScriptAnsweringDriver { + ScriptAnsweringDriver( + connection: TestFixtures.makeConnection(database: "warehouse", type: .mssql), + sendsBatchesWhole: sendsBatchesWhole, + transactionState: transactionState, + answer: answer + ) + } + + private func run( + _ text: String, + on driver: ScriptAnsweringDriver, + maxRows: Int = 500 + ) async throws -> DatabaseAccessBridge.ScriptOutcome { + let scope = install(driver) + defer { DatabaseManager.shared.removeSession(for: driver.connection.id) } + return try await DatabaseAccessBridge().runScript( + scope: scope, + query: text, + maxRows: maxRows, + timeoutSeconds: 30, + cancellation: nil + ) + } + + @Test("The #3078 script reaches the server as one batch and every result set comes back") + func reporterScriptReturnsEveryResultSet() async throws { + let driver = makeDriver(answer: Self.fourResultSets) + + let outcome = try await run(Self.reporterScript, on: driver, maxRows: 7) + + let sent = try #require(driver.sentBatches.first) + #expect(driver.sentBatches.count == 1) + #expect(sent.sql.hasPrefix("DECLARE @sn")) + #expect(sent.sql.hasSuffix("WHERE sn_code = @sn")) + #expect(sent.rowCap == 7) + #expect(driver.sentStatements.isEmpty) + #expect(outcome.resultSets.map(\.columns) == [ + ["S/N", "model"], ["S/N", "warehouse"], ["Serial number"], ["sn_code", "seen_at"] + ]) + #expect(outcome.primary.columns == ["S/N", "model"]) + #expect(outcome.rowsReturned == 4) + } + + @Test("A procedure call returns every result set it produced") + func procedureCallReturnsEveryResultSet() async throws { + let driver = makeDriver(answer: Self.fourResultSets) + + let outcome = try await run("EXEC sp_help 'dbo.orders'", on: driver) + + #expect(driver.sentBatches.map(\.sql) == ["EXEC sp_help 'dbo.orders'"]) + #expect(outcome.resultSets.count == 4) + } + + @Test("GO lines cut a script into batches, run a batch as often as they ask, and never reach the server") + func goLinesCutTheScript() async throws { + let driver = makeDriver { query in + ScriptAnsweringDriver.batch([ScriptAnsweringDriver.resultSet(columns: [query], rows: [["1"]])]) + } + + let outcome = try await run("SELECT 1 AS a\nGO\nSELECT 2 AS b\nGO 3", on: driver) + + #expect(driver.sentBatches.map(\.sql) == ["SELECT 1 AS a", "SELECT 2 AS b", "SELECT 2 AS b", "SELECT 2 AS b"]) + #expect(outcome.resultSets.count == 4) + } + + @Test("A lone query keeps the path that bounds its fetch, and a GO line before it is not sent") + func loneQueryKeepsTheBoundedPath() async throws { + let driver = makeDriver(answer: Self.fourResultSets) + + let outcome = try await run("GO\nSELECT * FROM orders", on: driver) + + #expect(driver.sentBatches.isEmpty) + #expect(driver.sentStatements == ["SELECT * FROM orders"]) + #expect(outcome.resultSets.count == 1) + } + + @Test("Rows the script changed and a transaction it left open are reported on the first result") + func scriptTotalsAndNoticesAreReported() async throws { + let notice = try #require(PluginSessionTransactionState.inTransaction.openTransactionNotice) + let driver = makeDriver(transactionState: .inTransaction) { _ in + ScriptAnsweringDriver.batch( + [ScriptAnsweringDriver.resultSet(columns: ["id"], rows: [["1"]], isTruncated: true)], + rowsAffected: 3 + ) + } + + let outcome = try await run("BEGIN TRAN\nUPDATE orders SET paid = 1\nSELECT id FROM orders", on: driver) + + #expect(outcome.primary.rowsAffected == 3) + #expect(outcome.primary.isTruncated) + #expect(outcome.primary.statusMessage == notice) + } + + @Test("A batch that raises an error fails the call, names the batch and line, and stops the script") + func batchErrorFailsTheCall() async throws { + let driver = makeDriver { query in + guard query.contains("missing") else { + return ScriptAnsweringDriver.batch([ScriptAnsweringDriver.resultSet(columns: ["n"], rows: [["1"]])]) + } + return ScriptAnsweringDriver.batch( + [], + errors: [ + PluginBatchError( + message: "Invalid object name 'missing'.", + code: 208, + line: 1, + procedure: nil, + precedingResultSetCount: 0 + ) + ] + ) + } + + let error = await #expect(throws: DatabaseError.self) { + try await run("SELECT 1\nGO\nSELECT * FROM missing\nGO\nSELECT 3", on: driver) + } + + let message = try #require(error?.errorDescription) + #expect(message.contains("Batch 2/3 failed: Line 3: Invalid object name 'missing'.")) + #expect(message.contains(String(localized: "The batch before it stays applied."))) + #expect(driver.sentBatches.map(\.sql) == ["SELECT 1", "SELECT * FROM missing"]) + } + + @Test("Result sets past the ceiling one batch keeps are counted and reported") + func resultSetsPastTheCeilingAreReported() async throws { + let driver = makeDriver { _ in + ScriptAnsweringDriver.batch([ScriptAnsweringDriver.resultSet(columns: ["n"], rows: [["1"]])]) + } + + let outcome = try await run("SELECT 1 AS n\nGO 102", on: driver) + + #expect(driver.sentBatches.count == 102) + #expect(outcome.resultSets.count == QueryBatchResult.keptResultSetLimit) + #expect(outcome.primary.statusMessage == BatchRunNotice.text(discardedResultSetCount: 2, sessionState: .idle)) + } + + @Test("A driver that cannot send a batch whole refuses a script rather than run part of it") + func scriptWithoutBatchDriverIsRefused() async throws { + let driver = makeDriver(sendsBatchesWhole: false) + + let error = await #expect(throws: DatabaseAccessError.self) { + try await run("SELECT 1; SELECT 2", on: driver) + } + + guard case .invalidArgument? = error else { + Issue.record("Expected an invalid argument, got \(String(describing: error))") + return + } + #expect(driver.sentStatements.isEmpty) + } + + @Test("GO with a count is refused by a driver that cannot send a batch whole") + func repeatedBatchWithoutBatchDriverIsRefused() async throws { + let driver = makeDriver(sendsBatchesWhole: false) + + let error = await #expect(throws: DatabaseAccessError.self) { + try await run("SELECT 1\nGO 2", on: driver) + } + + guard case .invalidArgument? = error else { + Issue.record("Expected an invalid argument, got \(String(describing: error))") + return + } + #expect(driver.sentStatements.isEmpty) + } +} diff --git a/TableProTests/Core/Execution/ExternalStatementGateTests.swift b/TableProTests/Core/Execution/ExternalStatementGateTests.swift index 6a8d36734c..29234a7a73 100644 --- a/TableProTests/Core/Execution/ExternalStatementGateTests.swift +++ b/TableProTests/Core/Execution/ExternalStatementGateTests.swift @@ -75,6 +75,31 @@ struct ExternalStatementGateTests { #expect(refusal(statement("SELECT 1; SELECT 2", allowsMultiStatement: true)) == nil) } + /// SQL Server runs whatever one request carries as one batch, `;` or not, and a variable lives only in the batch + /// that declares it, so a caller that takes scripts may send one there (#3078). Nowhere else. + @Test("Only an engine that cuts scripts into GO batches takes a script in one call") + func scriptsAreTakenWhereBatchesAreTheUnit() { + #expect(ExternalStatementGate.acceptsScripts(on: .mssql)) + for engine: DatabaseType in [.postgresql, .mysql, .sqlite, .oracle, .clickhouse] { + #expect(!ExternalStatementGate.acceptsScripts(on: engine), "\(engine.rawValue) takes one statement per call") + } + } + + @Test("A SQL Server script clears the gate for a caller that takes scripts, and is still tiered by its worst statement") + func sqlServerScriptIsGatedWhole() { + let script = "DECLARE @sn NVARCHAR(50) = 'x';\nSELECT * FROM a WHERE sn = @sn;\nGO\nSELECT * FROM b" + let takesScripts = ExternalStatementGate.acceptsScripts(on: .mssql) + #expect(refusal(statement(script, databaseType: .mssql, allowsMultiStatement: takesScripts)) == nil) + #expect(refusal(statement(script, databaseType: .mssql, externalAccess: .readOnly, allowsMultiStatement: takesScripts)) + == .denied(String(localized: "This connection is read only for external clients."))) + #expect(refusal(statement( + "SELECT 1;\nGO\nDROP TABLE orders", + databaseType: .mssql, + allowsDestructive: false, + allowsMultiStatement: takesScripts + )) == .denied(String(localized: "This statement drops or truncates data."))) + } + /// The connection setting a user reaches for when they want a script to look but not touch. @Test("A write is refused when the connection is read only for external clients", arguments: [ ExternalAccessLevel.readOnly, ExternalAccessLevel.blocked diff --git a/TableProTests/Core/MCP/MCPScriptResultTests.swift b/TableProTests/Core/MCP/MCPScriptResultTests.swift new file mode 100644 index 0000000000..2ba57cd54d --- /dev/null +++ b/TableProTests/Core/MCP/MCPScriptResultTests.swift @@ -0,0 +1,129 @@ +// +// MCPScriptResultTests.swift +// TableProTests +// +// execute_query, and the assistant's tool of the same name, answer a SQL Server script with every result set it +// returned. The fields a client already reads keep describing one result, the first, so a client that knows nothing +// of scripts reads what it always read (#3078). +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private actor DiscardingHistory: QueryHistoryRecording { + func record(_ request: QueryHistoryRecordRequest) async -> Bool { true } +} + +@Suite("MCP answers a SQL Server script with every result set", .serialized) +@MainActor +struct MCPScriptResultTests { + private let scope = DatabaseScope(connectionId: UUID(), database: "warehouse", schema: "dbo") + + private func scriptOutcome( + _ resultSets: [QueryResult], + rowsAffected: Int = 0, + sessionState: PluginSessionTransactionState = .idle + ) -> DatabaseAccessBridge.ScriptOutcome { + ScriptBatchRun( + answers: [ScriptAnsweringDriver.batch(resultSets, rowsAffected: rowsAffected)], + sessionState: sessionState + ).outcome(executionTimeMs: 12) + } + + @Test("The top-level fields are the first result set, and result_sets lists every one of them") + func scriptListsEveryResultSet() throws { + let outcome = scriptOutcome( + [ + ScriptAnsweringDriver.resultSet(columns: ["a"], rows: [["1"]]), + ScriptAnsweringDriver.resultSet(columns: ["b", "c"], rows: [["x", "y"], ["z", "w"]], isTruncated: true) + ], + rowsAffected: 5 + ) + + let payload = MCPConnectionBridge.encode(script: outcome, scope: scope) + + #expect(payload["columns"]?.arrayValue?.compactMap(\.stringValue) == ["a"]) + #expect(payload["row_count"]?.intValue == 1) + #expect(payload["rows_affected"]?.intValue == 5) + #expect(payload["is_truncated"]?.boolValue == false) + #expect(payload["database"]?.stringValue == "warehouse") + #expect(payload["status_message"] == nil) + let resultSets = try #require(payload["result_sets"]?.arrayValue) + #expect(resultSets.map { $0["columns"]?.arrayValue?.compactMap(\.stringValue) } == [["a"], ["b", "c"]]) + #expect(resultSets.map { $0["row_count"]?.intValue } == [1, 2]) + #expect(resultSets.map { $0["is_truncated"]?.boolValue } == [false, true]) + #expect(resultSets[1]["rows"]?.arrayValue?.first?.arrayValue?.compactMap(\.stringValue) == ["x", "y"]) + } + + @Test("A single result carries no result_sets, so the payload is the one a statement always had") + func singleResultKeepsTheStatementShape() { + let result = ScriptAnsweringDriver.resultSet(columns: ["n"], rows: [["1"]]) + + let payload = MCPConnectionBridge.encode(script: .statement(result, executionTimeMs: 3), scope: scope) + + #expect(payload["result_sets"] == nil) + #expect(payload == MCPConnectionBridge.encode(result: result, scope: scope, executionTimeMs: 3)) + } + + @Test("A script that leaves a transaction open says so in status_message") + func openTransactionIsReported() throws { + let notice = try #require(PluginSessionTransactionState.inTransaction.openTransactionNotice) + + let payload = MCPConnectionBridge.encode( + script: scriptOutcome([], rowsAffected: 2, sessionState: .inTransaction), + scope: scope + ) + + #expect(payload["status_message"]?.stringValue == notice) + #expect(payload["rows_affected"]?.intValue == 2) + #expect(payload["columns"]?.arrayValue?.isEmpty == true) + } + + @Test("execute_query declares result_sets in its output schema and keeps the fields it required") + func outputSchemaDeclaresResultSets() { + let schema = ExecuteQueryTool.outputSchema + #expect(schema?["properties"]?["result_sets"]?["items"]?["properties"]?["columns"] != nil) + #expect(schema?["required"] == MCPToolSchema.resultSet["required"]) + #expect(MCPToolSchema.resultSet["properties"]?["result_sets"] == nil) + } + + @Test("The query executor runs a script and answers with every result set") + func executorRunsTheTextAsAScript() async throws { + let driver = ScriptAnsweringDriver( + connection: TestFixtures.makeConnection(database: "warehouse", type: .mssql) + ) { _ in + ScriptAnsweringDriver.batch([ + ScriptAnsweringDriver.resultSet(columns: ["a"], rows: [["1"]]), + ScriptAnsweringDriver.resultSet(columns: ["b"], rows: [["2"], ["3"]]) + ]) + } + var session = ConnectionSession(connection: driver.connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: driver.connection.id) + defer { DatabaseManager.shared.removeSession(for: driver.connection.id) } + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy( + connectionResolver: { _ in nil }, + connectionIdsProvider: { [] }, + historyRecorder: DiscardingHistory() + ) + ) + + let payload = try await ToolQueryExecutor.executeAndLog( + services: services, + query: "DECLARE @n INT = 1;\nSELECT @n AS a;\nSELECT 2 AS b", + scope: DatabaseScope(connectionId: driver.connection.id, database: "warehouse", schema: nil), + maxRows: 10, + timeoutSeconds: 30, + principal: MCPToolTestHarness.principal(), + unit: .script + ) + + #expect(driver.sentBatches.map(\.rowCap) == [10]) + #expect(payload["result_sets"]?.arrayValue?.count == 2) + #expect(payload["columns"]?.arrayValue?.compactMap(\.stringValue) == ["a"]) + } +} diff --git a/TableProTests/Core/Scripting/ScriptResultEncoderTests.swift b/TableProTests/Core/Scripting/ScriptResultEncoderTests.swift index db5b8d13c0..a1166b559b 100644 --- a/TableProTests/Core/Scripting/ScriptResultEncoderTests.swift +++ b/TableProTests/Core/Scripting/ScriptResultEncoderTests.swift @@ -29,7 +29,7 @@ struct ScriptResultEncoderTests { error: nil ) - let record = ScriptResultEncoder.encode(result, executionTimeMs: 12.5) + let record = ScriptResultEncoder.encode(.statement(result, executionTimeMs: 12.5)) #expect(record[ScriptingKeys.QueryResult.columns] as? [String] == ["id", "name"]) #expect(record[ScriptingKeys.QueryResult.rowCount] as? Int == 2) @@ -51,7 +51,7 @@ struct ScriptResultEncoderTests { error: nil ) - let record = ScriptResultEncoder.encode(result, executionTimeMs: 0) + let record = ScriptResultEncoder.encode(.statement(result, executionTimeMs: 0)) #expect(try rows(of: record) == [["", payload.base64EncodedString()]]) } @@ -93,6 +93,56 @@ struct ScriptResultEncoderTests { #expect(try rows(of: record) == [["1"]]) } + /// The record is the first result, as it always was, so a script written before scripts existed reads the same + /// thing. `results` holds every result set of a SQL Server script that returned more than one (#3078). + @Test("A script's record is its first result set and lists every result set in results") + func scriptListsEveryResultSet() throws { + let outcome = ScriptBatchRun( + answers: [ + QueryBatchResult( + resultSets: [ + ScriptAnsweringDriver.resultSet(columns: ["a"], rows: [["1"]]), + ScriptAnsweringDriver.resultSet(columns: ["b", "c"], rows: [["x", "y"]], isTruncated: true) + ], + rowsAffected: 4, + errors: [], + discardedResultSetCount: 0, + executionTime: 0 + ) + ], + sessionState: .idle + ).outcome(executionTimeMs: 9) + + let record = ScriptResultEncoder.encode(outcome) + + #expect(record[ScriptingKeys.QueryResult.columns] as? [String] == ["a"]) + #expect(record[ScriptingKeys.QueryResult.rowsAffected] as? Int == 4) + let results = try #require(record[ScriptingKeys.QueryResult.results] as? [[String: Any]]) + #expect(results.map { $0[ScriptingKeys.QueryResult.columns] as? [String] } == [["a"], ["b", "c"]]) + #expect(try rows(of: results[1]) == [["x", "y"]]) + #expect(results.map { $0[ScriptingKeys.QueryResult.truncated] as? Bool } == [false, true]) + for key in ScriptingKeys.ResultSet.all { + #expect(results.allSatisfy { $0[key] != nil }, "'\(key)' is missing from a result set") + } + } + + @Test("A single result leaves results empty rather than repeating the rows") + func singleResultLeavesResultsEmpty() throws { + let result = QueryResult( + columns: ["n"], + columnTypes: [], + rows: [[.text("1")]], + rowsAffected: 0, + executionTime: 0, + error: nil + ) + + let record = ScriptResultEncoder.encode(.statement(result, executionTimeMs: 1)) + + let results = try #require(record[ScriptingKeys.QueryResult.results] as? [[String: Any]]) + #expect(results.isEmpty) + } + @Test("A statement that changed rows reports how many, and whether it was cut short") func carriesAffectedAndTruncated() throws { var result = QueryResult( @@ -106,7 +156,7 @@ struct ScriptResultEncoderTests { result.isTruncated = true result.statusMessage = "UPDATE 7" - let record = ScriptResultEncoder.encode(result, executionTimeMs: 3) + let record = ScriptResultEncoder.encode(.statement(result, executionTimeMs: 3)) #expect(record[ScriptingKeys.QueryResult.rowsAffected] as? Int == 7) #expect(record[ScriptingKeys.QueryResult.truncated] as? Bool == true) diff --git a/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift b/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift index 11e2caa11d..b204d26c3a 100644 --- a/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift +++ b/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift @@ -271,6 +271,20 @@ struct ScriptingDictionaryTests { } } + /// Measured with a throwaway scriptable app: a property named `result sets` beside a record type named + /// `result set` compiles, and AppleScript then reads it as every element of that type and answers `{}`. + @Test("No record property is named the plural of a record type") + func propertiesAreNotPluralsOfRecordTypes() throws { + let records = try suite().elements(forName: "record-type") + let plurals = Set(records.compactMap { $0.attribute(forName: "name")?.stringValue }.map { $0 + "s" }) + for record in records { + for property in record.elements(forName: "property") { + let name = property.attribute(forName: "name")?.stringValue ?? "" + #expect(!plurals.contains(name), "'\(name)' reads as every element of a record type, not the property") + } + } + } + // MARK: - The encoder and the dictionary agree @Test("Every record key the encoder writes is declared in the dictionary") @@ -287,6 +301,16 @@ struct ScriptingDictionaryTests { } } + @Test("Every key of a result set is declared on the result set record type") + func resultSetKeysAreDeclared() throws { + let resultSet = try #require( + try suite().elements(forName: "record-type") + .first { $0.attribute(forName: "name")?.stringValue == "result set" } + ) + let declared = Set(resultSet.elements(forName: "property").map { cocoaKey(of: $0) }) + #expect(declared == Set(ScriptingKeys.ResultSet.all)) + } + @Test("Every command parameter key the commands read is declared in the dictionary") func parameterKeysAreDeclared() throws { var declared: Set = [] diff --git a/TableProTests/Core/Services/Execution/QueryBatchResultTests.swift b/TableProTests/Core/Services/Execution/QueryBatchResultTests.swift new file mode 100644 index 0000000000..b7a95648c9 --- /dev/null +++ b/TableProTests/Core/Services/Execution/QueryBatchResultTests.swift @@ -0,0 +1,55 @@ +// +// QueryBatchResultTests.swift +// TableProTests +// +// `GO n` sends a batch n times, and the editor and every external caller fold the answers into one with +// `followed(by:)`, so the ceiling, the counts and the place of each error are decided in one place. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Answers of a repeated batch") +struct QueryBatchResultTests { + private func answer(resultSets: Int, rowsAffected: Int = 0, errorAfter: Int? = nil) -> QueryBatchResult { + QueryBatchResult( + resultSets: (0.. QueryBatchResult + private let lock = NSLock() + private var batches: [SentBatch] = [] + private var statements: [String] = [] + + init( + connection: DatabaseConnection, + sendsBatchesWhole: Bool = true, + transactionState: PluginSessionTransactionState = .idle, + answer: @escaping @Sendable (String) -> QueryBatchResult = { _ in .empty } + ) { + self.connection = connection + self.sendsBatchesWhole = sendsBatchesWhole + self.transactionState = transactionState + self.answer = answer + } + + var sentBatches: [SentBatch] { + lock.withLock { batches } + } + + var sentStatements: [String] { + lock.withLock { statements } + } + + var supportsResultSetBatches: Bool { sendsBatchesWhole } + + func executeBatch(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryBatchResult? { + guard sendsBatchesWhole else { return nil } + lock.withLock { batches.append(SentBatch(sql: query, rowCap: rowCap)) } + return answer(query) + } + + func sessionTransactionState() async -> PluginSessionTransactionState { + transactionState + } + + private func record(_ query: String) -> QueryResult { + lock.withLock { statements.append(query) } + return Self.resultSet(columns: ["n"], rows: [["1"]]) + } + + func execute(query: String) async throws -> QueryResult { record(query) } + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { record(query) } + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + record(query) + } + + static func resultSet(columns: [String], rows: [[String]], isTruncated: Bool = false) -> QueryResult { + var result = QueryResult( + columns: columns, + columnTypes: columns.map { _ in .text(rawType: nil) }, + rows: rows.map { row in row.map(PluginCellValue.fromOptional) }, + rowsAffected: 0, + executionTime: 0, + error: nil + ) + result.isTruncated = isTruncated + return result + } + + static func batch( + _ resultSets: [QueryResult], + rowsAffected: Int = 0, + errors: [PluginBatchError] = [] + ) -> QueryBatchResult { + QueryBatchResult( + resultSets: resultSets, + rowsAffected: rowsAffected, + errors: errors, + discardedResultSetCount: 0, + executionTime: 0 + ) + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func ping() async throws {} + func cancelQuery() throws {} + func applyQueryTimeout(_ seconds: Int) async throws {} + + func fetchTables() async throws -> [TableInfo] { [] } + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { [:] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, + name: database, + tableCount: nil, + sizeBytes: nil, + lastAccessed: nil, + isSystemDatabase: false, + icon: "cylinder" + ) + } + + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, + dataSize: nil, + indexSize: nil, + totalSize: nil, + avgRowLength: nil, + rowCount: nil, + comment: nil, + engine: nil, + collation: nil, + createTime: nil, + updateTime: nil + ) + } + + func fetchViewDefinition(view: String) async throws -> String { "" } + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} +} diff --git a/docs/external-api/applescript.mdx b/docs/external-api/applescript.mdx index bb44002867..0391da4f86 100644 --- a/docs/external-api/applescript.mdx +++ b/docs/external-api/applescript.mdx @@ -105,9 +105,15 @@ A record. Assign it to a variable and the rows stay with it. | `truncated` | boolean | True when `row limit` cut the result short | | `execution time` | real | Milliseconds | | `status message` | text | What the server said, when it said anything | +| `results` | list of result set | Every result set, when a SQL Server script returned more than one. Empty otherwise | A cell is always text. NULL is the empty string, and binary is Base64. +### result set + +One entry of `results`, with the `columns`, `rows`, `row count`, `truncated` and `status message` +of that result set. + ## Commands ### run query @@ -124,6 +130,24 @@ connection opens first if it is closed, which can prompt for a password. Several statements in one call are refused, as are statements that read files or run server-side code. +SQL Server is the exception to one statement per call: send the whole script, and it runs batch by +batch at its `GO` lines, as the query editor runs it. The record is the first result set, +`rows affected` counts the whole script, and `results` holds every result set when there is more +than one. `row limit` caps each result set on its own. + +```applescript +tell application "TablePro" + set r to run query "EXEC sp_help 'dbo.orders'" in connection "Warehouse" + set sets to results of r + repeat with one in sets + columns of one + end repeat +end tell +``` + +Read `results` into a variable before counting it. Inside a `tell` block, `count of results of r` +is sent to TablePro and answers 0. + AppleScript gives up on any command after two minutes. Wrap a long query in `with timeout of 300 seconds`. diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index 3f3da80e2a..2e2821466c 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -41,7 +41,7 @@ Past the scope, a call clears three more gates: `confirm_destructive_operation` needs `admin` as well, so it takes a Full Access token, and **Read Only** refuses it outright whatever the token carries. No token skips the user's approval. -Statements that read or write files, or that run server-side code, are refused on every tool. So is more than one statement in a single call. +Statements that read or write files, or that run server-side code, are refused on every tool. So is more than one statement in a single call, except a SQL Server script sent to [`execute_query`](#execute_query). ## Connections @@ -105,7 +105,7 @@ A lost connection, or a database where no schema could be read, fails the call i |------|-----------|---------| | `browse_table` | `connection_id`, `table` (`columns`, `filters`, `logic`, `sort`, `limit`, `offset`, `timeout_seconds`, `database`, `schema`) | A result set | | `count_rows` | `connection_id`, `table` (`exact`, `filters`, `logic`, `database`, `schema`) | `table`, `row_count`, `is_approximate`, `filter_count` | -| `execute_query` | `connection_id`, `query` (`max_rows`, `timeout_seconds`, `database`, `schema`) | A result set | +| `execute_query` | `connection_id`, `query` (`max_rows`, `timeout_seconds`, `database`, `schema`) | A result set, plus `result_sets[]` when a SQL Server script returns more than one | | `explain_query` | `connection_id`, `query` (`analyze`, `variant`, `timeout_seconds`, `database`, `schema`) | `statement` as actually sent, `execution_time_ms`, `columns[]`, `rows[][]`, and where available `plan_text`, `plan` and `available_variants[]` | | `export_data` | `connection_id`, `format` (`query` **or** `tables`, `sql_table`, `output_path`, `max_rows`, `database`, `schema`) | `format`, `rows_exported`, `is_truncated`, `path` when a file was written, and `exports[]` (`label`, `row_count`, `is_truncated`, `data`) | | `quote_identifiers` | `connection_id` and at least one of `identifiers`, `literals` (`database`, `schema`) | `identifiers[]` as `{ input, quoted }` and `literals[]` as `{ input, escaped }`, in the order supplied | @@ -128,6 +128,12 @@ Without filters this returns the engine's fast estimate unless `exact` is set. W One statement, 100 KB at most. `DROP` and `TRUNCATE` are refused here; use `confirm_destructive_operation`. When the request carries a `progressToken`, the tool emits progress at 0.0 (resolving), 0.3 (executing) and 1.0 (done). +On SQL Server, send the whole script instead. A variable exists only in the batch that declares it, so a `DECLARE` and the `SELECT`s that read it belong in one call. The script is cut into batches at its `GO` lines, as the query editor cuts it, and `GO 5` runs a batch five times. The gates judge the script by its worst statement: one write anywhere needs `tools:write` and Safe Mode, and one `DROP` refuses the call. `DECLARE` counts as a write. + +The top-level fields describe the first result set, with `rows_affected` counting the whole script. When the script returns more than one result set, `result_sets[]` lists every one in order, each with `columns`, `rows`, `row_count` and `is_truncated`. `max_rows` caps each result set on its own. `status_message` says when the script left a transaction open. + +A batch that raises an error fails the call with the server's message and line number. The batches after it do not run, and the error says what stays applied. + ### `explain_query` Pass the query with no `EXPLAIN` prefix. Without `variant` the engine's first variant runs, and every result lists them in `available_variants[]`. `analyze: true` picks the variant that runs the statement, so an analyzed write needs `tools:write` and Safe Mode approval, and it is refused when the engine has no such variant or the `variant` you pass only estimates. An engine missing from the [support table](/features/explain-visualization#database-support) is refused.