diff --git a/CHANGELOG.md b/CHANGELOG.md index 15389e0d2..674a0591f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tips in the iOS connection list for swiping to favorite, touch and hold, and tag search. - Acknowledgements and a privacy policy link under **Settings > About** on iPhone and iPad. - Privacy manifest for the iOS app. +- Oracle `DBMS_OUTPUT` lines shown with the result of the statement that printed them, and in a new **Output** result view. ### Changed diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index 2fe89086f..7dae05a0b 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -60,6 +60,7 @@ public final class OracleCoreConnection: @unchecked Sendable { var nioConnection: OracleNIO.OracleConnection? var queryTimeoutSeconds = 0 var sessionSchema: String? + var capturesServerOutput = false } private let state = OSAllocatedUnfairLock(initialState: LockedState()) @@ -324,9 +325,74 @@ public final class OracleCoreConnection: @unchecked Sendable { try await collectRows(OracleSchemaQueries.setCurrentSchema(schema), on: connection) } } + if state.withLock({ $0.capturesServerOutput }) { + _ = try await withQueryDeadline { [self] in + try await collectRows(OracleServerOutput.enableStatement, on: connection) + } + } return connection } + // MARK: - Server Output + + /// Turns `DBMS_OUTPUT` on for this session, and for every session a reconnect replaces it with, since the setting + /// belongs to the session and a new one starts with it off. + public func captureServerOutput() async throws { + state.withLock { $0.capturesServerOutput = true } + _ = try await executeQuery(OracleServerOutput.enableStatement) + } + + /// Reads and consumes the lines the session has written since the last read, at most `maxLines` of them. + /// + /// One round trip: `GET_LINES` fills a collection, the same block splits every line into pieces a SQL `VARCHAR2` + /// holds, and a cursor returns them. The split has to happen in PL/SQL. A line can be 32767 bytes, and measured on + /// Oracle 23ai with `MAX_STRING_SIZE=STANDARD` any SQL over a longer-than-4000-byte element fails with ORA-00910, + /// which oracle-nio does not throw: a failure while the block opens its cursor ends the process inside the driver. + /// + /// A session that is closed has lost its buffer with it, so it reads as no output rather than paying for a + /// reconnect: a query timeout or a dropped transport closes the connection, and the statement's error would + /// otherwise wait on a whole login before it could be shown. + public func drainServerOutput(maxLines: Int) async throws -> OracleServerOutput { + guard state.withLock({ $0.capturesServerOutput }), maxLines > 0 else { return .empty } + await queryGate.acquire() + + guard let connection = state.withLock({ $0.isConnected ? $0.nioConnection : nil }) else { + await queryGate.release() + return .empty + } + do { + let output = try await withQueryDeadline { [self] in + try await readServerOutput(on: connection, maxLines: maxLines) + } + await queryGate.release() + return output + } catch { + let mapped = mapExecutionError(error) + await queryGate.release() + throw mapped + } + } + + private func readServerOutput( + on connection: OracleNIO.OracleConnection, + maxLines: Int + ) async throws -> OracleServerOutput { + let countRef = OracleRef(dataType: .number) + let cursorRef = OracleRef(dataType: .cursor) + var binds = OracleBindings() + binds.append(countRef, bindName: OracleServerOutput.lineCountBindName, isReturning: false) + binds.append(cursorRef, bindName: OracleServerOutput.piecesBindName, isReturning: false) + let statement = OracleStatement(unsafeSQL: OracleServerOutput.drainBlock(maxLines: maxLines), binds: binds) + try await connection.execute(statement, logger: nioLogger) + let count: Int = try countRef.decode() + let cursor = try cursorRef.decode(as: Cursor.self) + var pieces: [String?] = [] + for try await row in try await cursor.execute(on: connection, logger: nioLogger) { + pieces.append(try row.decode(String?.self)) + } + return OracleServerOutput.read(pieces: pieces, reportedCount: count, cap: maxLines) + } + /// Races the operation against the configured query timeout. On timeout the /// connection is closed first, which fails the in-flight OracleNIO call even /// if it ignores task cancellation, so the race can always unwind. diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleServerOutput.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleServerOutput.swift new file mode 100644 index 000000000..e94d3279d --- /dev/null +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleServerOutput.swift @@ -0,0 +1,98 @@ +import Foundation + +/// The lines a session wrote with `DBMS_OUTPUT` since they were last read. +/// +/// Oracle buffers `DBMS_OUTPUT.PUT_LINE` on the server once the session has called `DBMS_OUTPUT.ENABLE`, and hands the +/// lines back only when asked with `GET_LINES`, which consumes them. SQL*Plus's `SET SERVEROUTPUT ON` is the same pair of +/// calls. +public struct OracleServerOutput: Sendable, Equatable { + public let lines: [String] + + /// Whether the buffer held more than was read. The rest is discarded, so the next read starts clean rather than + /// with lines that belong to an earlier statement. + public let isTruncated: Bool + + public static let empty = OracleServerOutput(lines: [], isTruncated: false) + + public init(lines: [String], isTruncated: Bool) { + self.lines = lines + self.isTruncated = isTruncated + } + + /// `NULL` for no limit on the server's buffer, as `SET SERVEROUTPUT ON` asks for. + /// + /// Every package and type is named with its owner. A bare `DBMS_OUTPUT` resolves to an object of that name in the + /// session's current schema before the public synonym, so anyone who can create one there would have it run with + /// the reader's privileges after every statement. + static let enableStatement = "BEGIN SYS.DBMS_OUTPUT.ENABLE(NULL); END;" + + static let lineCountBindName = "line_count" + static let piecesBindName = "pieces" + + /// Reads up to `maxLines` lines in one round trip, split into pieces a SQL `VARCHAR2` can carry. + /// + /// Asks for one line more than the cap, and discards the rest of the buffer when there was more, so the next read + /// starts with the next statement's lines. + static func drainBlock(maxLines: Int) -> String { + """ + DECLARE + l_lines SYS.DBMSOUTPUT_LINESARRAY; + l_pieces SYS.ODCIVARCHAR2LIST := SYS.ODCIVARCHAR2LIST(); + l_cap CONSTANT PLS_INTEGER := \(maxLines); + l_piece_length CONSTANT PLS_INTEGER := \(pieceLength); + l_count INTEGER := l_cap + 1; + l_line VARCHAR2(32767); + l_offset PLS_INTEGER; + BEGIN + SYS.DBMS_OUTPUT.GET_LINES(l_lines, l_count); + IF l_count > l_cap THEN + SYS.DBMS_OUTPUT.DISABLE; + SYS.DBMS_OUTPUT.ENABLE(NULL); + END IF; + <> + FOR i IN 1 .. LEAST(l_count, l_cap) LOOP + l_line := l_lines(i); + l_offset := 1; + LOOP + IF l_pieces.COUNT = l_pieces.LIMIT THEN + l_count := l_cap + 1; + EXIT each_line; + END IF; + l_pieces.EXTEND; + l_pieces(l_pieces.COUNT) := CASE WHEN l_offset = 1 THEN 'N' ELSE 'C' END + || SUBSTR(l_line, l_offset, l_piece_length); + l_offset := l_offset + l_piece_length; + EXIT WHEN l_offset > NVL(LENGTH(l_line), 0); + END LOOP; + END LOOP; + :\(lineCountBindName) := l_count; + OPEN :\(piecesBindName) FOR SELECT COLUMN_VALUE FROM TABLE(l_pieces); + END; + """ + } + + /// Characters per piece a line is returned in. With the one-character marker in front, a piece stays under the 4000 + /// bytes a SQL `VARCHAR2` holds even at four bytes a character, the widest any Oracle character set uses. + static let pieceLength = 999 + + private static let newLineMarker: Character = "N" + + /// Joins the pieces one `GET_LINES` call came back as, each marked `N` where a line starts and `C` where it + /// continues. + /// + /// The call is asked for one line more than `cap`, which is how a buffer holding more than `cap` is told apart + /// from one holding exactly `cap`. + static func read(pieces: [String?], reportedCount: Int, cap: Int) -> OracleServerOutput { + var lines: [String] = [] + for piece in pieces { + guard let piece, let marker = piece.first else { continue } + let text = String(piece.dropFirst()) + if marker == newLineMarker || lines.isEmpty { + lines.append(text) + } else { + lines[lines.count - 1] += text + } + } + return OracleServerOutput(lines: Array(lines.prefix(cap)), isTruncated: reportedCount > cap) + } +} diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerOutputTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerOutputTests.swift new file mode 100644 index 000000000..71fc3f054 --- /dev/null +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleServerOutputTests.swift @@ -0,0 +1,62 @@ +@testable import TableProOracleCore +import XCTest + +/// A `DBMS_OUTPUT` line can be 32767 bytes and a SQL value on a `MAX_STRING_SIZE=STANDARD` database only 4000, so +/// the driver reads each line as marked pieces and joins them here. +final class OracleServerOutputTests: XCTestCase { + func testPiecesJoinIntoTheLinesTheyCameFrom() { + let output = OracleServerOutput.read( + pieces: ["NHello", "N", "Nfirst half ", "Csecond half", "Nlast"], + reportedCount: 4, + cap: 10 + ) + XCTAssertEqual(output, OracleServerOutput( + lines: ["Hello", "", "first half second half", "last"], + isTruncated: false + )) + } + + func testMoreLinesThanTheCapReportsTruncation() { + let output = OracleServerOutput.read(pieces: ["Na", "Nb", "Nc"], reportedCount: 4, cap: 3) + XCTAssertEqual(output, OracleServerOutput(lines: ["a", "b", "c"], isTruncated: true)) + } + + func testAnEmptyBufferReadsAsNoOutput() { + XCTAssertEqual(OracleServerOutput.read(pieces: [], reportedCount: 0, cap: 10), .empty) + } + + func testAPieceWithoutAMarkerIsSkipped() { + let output = OracleServerOutput.read(pieces: [nil, "", "Nkept"], reportedCount: 1, cap: 10) + XCTAssertEqual(output.lines, ["kept"]) + } + + func testAPieceIsShortEnoughForAStandardVarchar() { + XCTAssertLessThanOrEqual(1 + OracleServerOutput.pieceLength * 4, 4_000) + } + + /// Measured on Oracle 23ai: a `DBMS_OUTPUT` package in the session's own schema received the `ENABLE` and + /// `GET_LINES` calls a bare name made, and the real line was never read. + func testEveryServerPackageAndTypeIsNamedWithItsOwner() { + let ownedNames = ["DBMS_OUTPUT", "DBMSOUTPUT_LINESARRAY", "ODCIVARCHAR2LIST"] + for sql in [OracleServerOutput.enableStatement, OracleServerOutput.drainBlock(maxLines: 10)] { + for name in ownedNames { + let occurrences = sql.components(separatedBy: name).count - 1 + let owned = sql.components(separatedBy: "SYS." + name).count - 1 + XCTAssertEqual(occurrences, owned, "\(name) is named without its owner in:\n\(sql)") + } + } + } + + func testTheBindsAppearInTheOrderTheyAreAppended() throws { + let block = OracleServerOutput.drainBlock(maxLines: 10) + let lineCount = try XCTUnwrap(block.range(of: ":" + OracleServerOutput.lineCountBindName)) + let pieces = try XCTUnwrap(block.range(of: ":" + OracleServerOutput.piecesBindName)) + XCTAssertLessThan(lineCount.lowerBound, pieces.lowerBound) + } + + func testTheBlockAsksForOneLineMoreThanTheCap() { + let block = OracleServerOutput.drainBlock(maxLines: 250) + XCTAssertTrue(block.contains("l_cap CONSTANT PLS_INTEGER := 250;")) + XCTAssertTrue(block.contains("l_count INTEGER := l_cap + 1;")) + } +} diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 739166534..01c8bd21b 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -247,6 +247,12 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } self.core = connection + do { + try await connection.captureServerOutput() + } catch { + Self.logger.warning("DBMS_OUTPUT could not be enabled for this session: \(String(describing: error), privacy: .public)") + } + if let result = try? await connection.executeQuery(OracleSchemaQueries.currentSchema), let schema = result.rows.first?.first?.stringValue { _currentSchema = schema @@ -320,6 +326,20 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return result.toPluginResult(executionTime: executionTime) } + /// At most this many lines are read after one statement. A loop that prints more is reported as truncated + /// rather than read into memory, and the rest of its buffer is discarded on the server. + static let serverOutputLineLimit = 10_000 + + func fetchServerOutput() async throws -> PluginServerOutput { + guard let core else { return .none } + do { + let output = try await core.drainServerOutput(maxLines: Self.serverOutputLineLimit) + return PluginServerOutput(lines: output.lines, isTruncated: output.isTruncated) + } catch let error as OracleCoreError { + throw error.asPluginError + } + } + /// Turns a `CREATE` that stored an INVALID unit into the failure it is. /// /// Oracle accepts the statement and flags the compile failure only as a warning, which oracle-nio drops, so the diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 82d57da4a..d673cdffc 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -177,6 +177,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// over one the user already has open. A driver that cannot ask keeps the `.unknown` default. func sessionTransactionState() async -> PluginSessionTransactionState + /// Reads and consumes what the session printed since the last read, which the app asks for after each statement + /// the editor runs, on the same session. A driver whose engine prints nothing keeps the empty default. + func fetchServerOutput() async throws -> PluginServerOutput + func cancelQuery() throws func applyQueryTimeout(_ seconds: Int) async throws @@ -639,6 +643,8 @@ public extension PluginDatabaseDriver { func sessionTransactionState() async -> PluginSessionTransactionState { .unknown } + func fetchServerOutput() async throws -> PluginServerOutput { .none } + func cancelQuery() throws {} func applyQueryTimeout(_ seconds: Int) async throws {} diff --git a/Plugins/TableProPluginKit/PluginServerOutput.swift b/Plugins/TableProPluginKit/PluginServerOutput.swift new file mode 100644 index 000000000..cb4967b1d --- /dev/null +++ b/Plugins/TableProPluginKit/PluginServerOutput.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Lines a session printed on the server since they were last read, such as Oracle's `DBMS_OUTPUT`. +public struct PluginServerOutput: Sendable, Equatable { + public let lines: [String] + + /// Whether the session printed more than the driver read. The rest is gone, not waiting for the next read. + public let isTruncated: Bool + + public static let none = PluginServerOutput(lines: [], isTruncated: false) + + public init(lines: [String], isTruncated: Bool) { + self.lines = lines + self.isTruncated = isTruncated + } + + public var isEmpty: Bool { + lines.isEmpty && !isTruncated + } +} diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index ce2525274..931b1601d 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -186,7 +186,8 @@ extension QueryExecutionCoordinator { historySQL: String? = nil, anchor: StatementAnchor? = nil, timing: PluginQueryTiming? = nil, - viewport: GridReloadIntent = .firstRow + viewport: GridReloadIntent = .firstRow, + serverOutput: PluginServerOutput = .none ) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } @@ -269,6 +270,7 @@ extension QueryExecutionCoordinator { rs.statusMessage = tab.execution.statusMessage rs.isTruncated = isTruncated rs.baseQuery = sql + rs.serverOutput = serverOutput tab.display.replaceUnpinnedResults(with: [rs]) @@ -783,9 +785,11 @@ extension QueryExecutionCoordinator { _ error: Error, sql: String, tabId: UUID, - connection conn: DatabaseConnection + connection conn: DatabaseConnection, + serverOutput: PluginServerOutput = .none ) { - let message = DatabaseWriteRejectionDiagnosis.formatted(error) + let diagnosis = DatabaseWriteRejectionDiagnosis.formatted(error) + let message = ServerOutputCapture.failureMessage(diagnosis, output: serverOutput) helpersLogger.error( "Query failed on tab \(tabId, privacy: .public): \(error.publicLogShape, privacy: .public)" ) @@ -807,7 +811,7 @@ extension QueryExecutionCoordinator { if parent.tabManager.selectedTabId == tabId { parent.toolbarState.isResultsCollapsed = false parent.toolbarState.clearQueryTiming(forTab: tabId) - parent.announceQueryError(message) + parent.announceQueryError(diagnosis) } recordHistory( diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index a5fd2a1b6..74e89817b 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -56,6 +56,7 @@ extension QueryExecutionCoordinator { resultSet.executionTime = result.executionTime resultSet.rowsAffected = result.rowsAffected resultSet.statusMessage = result.statusMessage + resultSet.serverOutput = result.serverOutput if !result.columns.isEmpty { resultSet.isTruncated = result.isTruncated resultSet.baseQuery = baseQuery diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 041d1ad46..b9c07a48d 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -26,6 +26,7 @@ private struct MultiStatementRun { let outcome: BatchStatementOutcome let plan: BatchTransactionPlan let sessionState: PluginSessionTransactionState + var failureOutput: PluginServerOutput = .none } private struct PreparedStatement: @unchecked Sendable { @@ -150,6 +151,7 @@ extension QueryExecutionCoordinator { ) let boundValues = BoundParameterValues(values: parameters) + let failureOutput = ServerOutputBox() let parameterizedTask = Task { [weak self, parent] in guard let self else { return } @@ -170,7 +172,8 @@ extension QueryExecutionCoordinator { driver: driver, sql: statement.sql, parameters: boundValues.values, - rowCap: rowCap + rowCap: rowCap, + capturingOutputInto: failureOutput ) } CatalogChangeService.post( @@ -240,7 +243,13 @@ extension QueryExecutionCoordinator { ]) return } - handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn) + handleQueryExecutionError( + error, + sql: sql, + tabId: tabId, + connection: conn, + serverOutput: failureOutput.output + ) reportOperation(kind: .query, claim: claim, outcome: .failed(reason: error.localizedDescription)) } } @@ -379,7 +388,8 @@ extension QueryExecutionCoordinator { tabId: tabId, claim: claim, statements: statements, - timing: PluginQueryTiming.batch(of: results) + timing: PluginQueryTiming.batch(of: results), + failureOutput: run.failureOutput ) } } @@ -426,8 +436,9 @@ extension QueryExecutionCoordinator { claim: TabExecutionClaim, lease: DriverLeaseOwner ) async -> MultiStatementRun { + let failureOutput = ServerOutputBox() do { - return try await DatabaseManager.shared.withScopedDriver( + var run = try await DatabaseManager.shared.withScopedDriver( scope: scope, route: DatabaseManager.shared.executionRoute(for: scope), cancellation: .cancellableRead(lease) @@ -443,12 +454,14 @@ extension QueryExecutionCoordinator { failureSQL: \.executableSQL, isCommitPoint: \.isCommitPoint ) { statement in - try await self.executeStatement( - rowCap: statement.rowCap, - originalSQL: statement.sentSQL, - driver: driver, - parameters: statement.parameterValues - ) + try await ServerOutputCapture.running(on: driver, failureOutput: failureOutput) { + try await self.executeStatement( + rowCap: statement.rowCap, + originalSQL: statement.sentSQL, + driver: driver, + parameters: statement.parameterValues + ) + } } guard sessionPlan == .sessionTransaction else { return MultiStatementRun(outcome: outcome, plan: sessionPlan, sessionState: .idle) @@ -459,6 +472,8 @@ extension QueryExecutionCoordinator { sessionState: await driver.heldSessionTransactionState() ) } + run.failureOutput = failureOutput.output + return run } catch { if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled { return MultiStatementRun(outcome: .cancelled(results: []), plan: plan, sessionState: .unknown) @@ -622,7 +637,8 @@ extension QueryExecutionCoordinator { queryParameterValues: originalParameters, historySQL: originalSQL, anchor: anchor, - timing: fetchResult.resolvedTiming + timing: fetchResult.resolvedTiming, + serverOutput: fetchResult.serverOutput ) let parameterValues = nativeParameters.map { $0 as? String } @@ -645,12 +661,13 @@ extension QueryExecutionCoordinator { tabId: UUID, claim: TabExecutionClaim, statements: [SQLStatementScanner.ExecutableStatement], - timing: PluginQueryTiming + timing: PluginQueryTiming, + failureOutput: PluginServerOutput ) { let cumulativeTime = timing.total let errorDescription = context.errorDescription let report = context.report() - let contextMsg = report.message + let contextMsg = ServerOutputCapture.failureMessage(report.message, output: failureOutput) let errorRS = ResultSet(label: report.resultLabel) errorRS.errorMessage = contextMsg @@ -688,7 +705,7 @@ extension QueryExecutionCoordinator { if parent.tabManager.selectedTabId == tabId { parent.toolbarState.isResultsCollapsed = false parent.toolbarState.recordQueryTiming(timing, for: tabId) - parent.announceQueryError(contextMsg) + parent.announceQueryError(report.message) } guard let rawSQL = failedStatementSQL else { return } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index e3cebd926..2f1509337 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -332,6 +332,9 @@ protocol DatabaseDriver: AnyObject, Sendable { /// transaction over one the user already has open on the same session. func sessionTransactionState() async -> PluginSessionTransactionState + /// Reads and consumes what the session printed on the server since the last read. + func fetchServerOutput() async throws -> PluginServerOutput + /// Access to the underlying plugin driver for query building dispatch var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { get } @@ -413,6 +416,8 @@ extension DatabaseDriver { func sessionTransactionState() async -> PluginSessionTransactionState { .unknown } + func fetchServerOutput() async throws -> PluginServerOutput { .none } + func quoteIdentifier(_ name: String) -> String { SQLEscaping.quoteIdentifier(name) } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 39b28b8bf..c2074b317 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -636,6 +636,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor await pluginDriver.sessionTransactionState() } + func fetchServerOutput() async throws -> PluginServerOutput { + try await pluginDriver.fetchServerOutput() + } + // MARK: - Schema Switching func switchSchema(to schema: String) async throws { diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 87dc1caf2..3b2208cc1 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -17,6 +17,9 @@ struct QueryFetchResult { /// What the elapsed time was spent on, when the driver could tell. var timing: PluginQueryTiming? + /// What the statement printed on the server, read on its own session. + var serverOutput: PluginServerOutput = .none + var resolvedTiming: PluginQueryTiming { timing ?? PluginQueryTiming(total: executionTime) } @@ -84,6 +87,23 @@ final class QueryExecutor { /// The driver is supplied by the caller, which resolved it from the tab's scope. /// Looking it up here would tie every query to whichever database the connection /// happens to be on. + /// Runs a statement and, when `failureOutput` is given, reads what it printed on the server; see + /// ``ServerOutputCapture``. A table tab's own reads pass nil, because nothing they run prints. + func executeQuery( + driver: DatabaseDriver, + sql: String, + parameters: [Any?]? = nil, + rowCap: Int?, + capturingOutputInto failureOutput: ServerOutputBox? + ) async throws -> QueryFetchResult { + guard let failureOutput else { + return try await executeQuery(driver: driver, sql: sql, parameters: parameters, rowCap: rowCap) + } + return try await ServerOutputCapture.running(on: driver, failureOutput: failureOutput) { + try await executeQuery(driver: driver, sql: sql, parameters: parameters, rowCap: rowCap) + } + } + func executeQuery( driver: DatabaseDriver, sql: String, diff --git a/TablePro/Core/Services/Query/ServerOutputCapture.swift b/TablePro/Core/Services/Query/ServerOutputCapture.swift new file mode 100644 index 000000000..1ca309184 --- /dev/null +++ b/TablePro/Core/Services/Query/ServerOutputCapture.swift @@ -0,0 +1,90 @@ +// +// ServerOutputCapture.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +/// A result that can carry what its statement printed on the server. +protocol CarriesServerOutput { + var serverOutput: PluginServerOutput { get set } +} + +extension QueryFetchResult: CarriesServerOutput {} +extension QueryResult: CarriesServerOutput {} + +/// Reads what a statement the editor ran printed on the server, on the session that ran it. +/// +/// The read happens after every statement, failed ones included, because the server hands the lines to whoever asks +/// next: output a failed statement left unread would be reported under the statement after it. A read that fails is +/// logged and reported as no output, since the statement it follows has already run. +enum ServerOutputCapture { + private static let logger = Logger(subsystem: "com.TablePro", category: "ServerOutputCapture") + + static func running( + on driver: DatabaseDriver, + failureOutput: ServerOutputBox, + _ statement: () async throws -> Value + ) async throws -> Value { + do { + var value = try await statement() + value.serverOutput = await drain(driver) + return value + } catch { + if !DatabaseCancellationDiagnosis.isCancellation(error) { + failureOutput.store(await drain(driver)) + } + throw error + } + } + + /// How much of a failed statement's output its error message carries. The message is laid out as text in the + /// error banner and handed to Fix with AI whole, and a loop can print 10,000 lines of 32,767 bytes before it fails. + static let failureLineLimit = 20 + static let failureLineLength = 500 + + /// The message a failed statement reports, followed by the first lines it printed before it failed. + static func failureMessage(_ message: String, output: PluginServerOutput) -> String { + guard !output.isEmpty else { return message } + var sections = [message, String(localized: "Output before the error:")] + sections.append(contentsOf: output.lines.prefix(failureLineLimit).map(clipped)) + if output.lines.count > failureLineLimit || output.isTruncated { + sections.append(String( + format: String(localized: "Only the first %lld lines are shown."), + Int64(failureLineLimit) + )) + } + return sections.joined(separator: "\n") + } + + private static func clipped(_ line: String) -> String { + let text = line as NSString + guard text.length > failureLineLength else { return line } + return text.substring(to: failureLineLength) + "…" + } + + private static func drain(_ driver: DatabaseDriver) async -> PluginServerOutput { + guard !Task.isCancelled else { return .none } + do { + return try await driver.fetchServerOutput() + } catch { + logger.warning("Server output could not be read: \(String(describing: error), privacy: .public)") + return .none + } + } +} + +/// Carries a failed statement's output from the driver's session to the main-actor code that reports the failure. +final class ServerOutputBox: Sendable { + private let state = OSAllocatedUnfairLock(initialState: PluginServerOutput.none) + + var output: PluginServerOutput { + state.withLock { $0 } + } + + func store(_ output: PluginServerOutput) { + state.withLock { $0 = output } + } +} diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 5755369a9..b8c3deac6 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -32,6 +32,9 @@ struct QueryResult { var columnMeta: [ResultColumnMeta]? + /// What the statement printed on the server, read on its own session. + var serverOutput: PluginServerOutput = .none + var isEmpty: Bool { rows.isEmpty } diff --git a/TablePro/Models/Query/QueryResultPresentation.swift b/TablePro/Models/Query/QueryResultPresentation.swift index e22a19cd6..8b7de7243 100644 --- a/TablePro/Models/Query/QueryResultPresentation.swift +++ b/TablePro/Models/Query/QueryResultPresentation.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit /// What the results pane draws. /// @@ -26,8 +27,16 @@ enum QueryResultContent: Equatable { case grid /// Columns came back and no rows did, which is a result rather than an absence. case noRows(executionTime: TimeInterval?) - /// A statement that reports work done rather than rows: INSERT, UPDATE, DDL. - case statementSucceeded(rowsAffected: Int, executionTime: TimeInterval?, statusMessage: String?) + /// A statement that reports work done rather than rows: INSERT, UPDATE, DDL, and whatever it printed on the + /// server, which for a PL/SQL block is usually the point of running it. + case statementSucceeded( + rowsAffected: Int, + executionTime: TimeInterval?, + statusMessage: String?, + serverOutput: PluginServerOutput + ) + /// What a statement that returned rows printed on the server, on its own in Output mode. + case serverOutput(PluginServerOutput) /// The mode draws the loaded buffer and the buffer is empty, so the mode cannot draw. case unavailable(mode: ResultsViewMode) } @@ -50,6 +59,7 @@ struct QueryResultInputs: Equatable { var activeResultRowsAffected = 0 var activeResultExecutionTime: TimeInterval? var activeResultStatusMessage: String? + var activeResultServerOutput: PluginServerOutput = .none /// A failed result carries its own message, which outlives the tab's. Pin a failure, run /// something that works, and `executionErrorMessage` is cleared while this one is not. var activeResultErrorMessage: String? @@ -111,6 +121,10 @@ struct QueryResultPresentation: Equatable { if inputs.isExecuting, inputs.loadedColumnCount == 0 { return .executing } + if inputs.viewMode == .output, !inputs.isExecuting, !inputs.activeResultServerOutput.isEmpty { + return .serverOutput(inputs.activeResultServerOutput) + } + /// Ahead of the idle rule below, because these two modes say why they are empty rather /// than going blank: a reader who switched to Chart before running anything is told to run /// something, which is the one thing a blank pane cannot say. @@ -156,7 +170,8 @@ struct QueryResultPresentation: Equatable { return .statementSucceeded( rowsAffected: inputs.activeResultRowsAffected, executionTime: inputs.activeResultExecutionTime, - statusMessage: inputs.activeResultStatusMessage + statusMessage: inputs.activeResultStatusMessage, + serverOutput: inputs.activeResultServerOutput ) } @@ -167,7 +182,8 @@ struct QueryResultPresentation: Equatable { return .statementSucceeded( rowsAffected: inputs.executionRowsAffected, executionTime: inputs.executionTime, - statusMessage: inputs.executionStatusMessage + statusMessage: inputs.executionStatusMessage, + serverOutput: .none ) } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 88eb15be8..4998fef61 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -12,13 +12,16 @@ enum ResultsViewMode: String, CaseIterable, Equatable { case json case chart case map + /// What the statement printed on the server, such as Oracle's `DBMS_OUTPUT`. Offered only for a result that + /// printed something. + case output /// How much of the loaded result the mode is showing, and how to load more. A chart draws the /// same buffer the grid does, so it needs the same scope controls: a warning that the chart is /// incomplete is only useful next to the control that completes it. A map draws that same - /// buffer, so the same argument puts it here. + /// buffer, so the same argument puts it here. Output is not drawn from the buffer at all. var showsResultScope: Bool { - self != .structure + self != .structure && self != .output } var showsColumnControls: Bool { diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index b4acd37d9..8f011c281 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -704,12 +704,23 @@ struct TabDisplayState: Equatable { mutating func replaceUnpinnedResults(with newResults: [ResultSet]) { resultSets = resultSets.filter { $0.isPinned } + newResults activeResultSetId = newResults.last?.id ?? resultSets.last?.id + leaveOutputModeWithoutOutput() } @MainActor mutating func removeUnpinnedResults() { resultSets = resultSets.filter { $0.isPinned } activeResultSetId = resultSets.last?.id + leaveOutputModeWithoutOutput() + } + + /// Output mode shows what the active result printed, so it cannot outlive a result that printed nothing. The + /// rows of a new result are installed, and their modes reconciled, before the result itself replaces the old + /// one, which is too early to see the new result's output. + @MainActor + private mutating func leaveOutputModeWithoutOutput() { + guard resultsViewMode == .output, activeResultSet?.serverOutput.isEmpty != false else { return } + resultsViewMode = .data } @MainActor diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index 405ca06fa..de13a42bf 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -8,6 +8,7 @@ import Combine import Foundation import os +import TableProPluginKit /// One execution's product: its rows, and the facts about how they were produced. /// @@ -31,6 +32,8 @@ final class ResultSet: ObservableObject, Identifiable { @Published var rowsAffected: Int = 0 @Published var errorMessage: String? @Published var statusMessage: String? + /// What the statement printed on the server, such as Oracle's `DBMS_OUTPUT`, read right after it ran. + @Published var serverOutput: PluginServerOutput = .none @Published var isPinned: Bool = false @Published var isTruncated: Bool = false @Published var baseQuery: String? diff --git a/TablePro/Models/Query/ResultsModeAvailability.swift b/TablePro/Models/Query/ResultsModeAvailability.swift index afbec2f25..09c90692e 100644 --- a/TablePro/Models/Query/ResultsModeAvailability.swift +++ b/TablePro/Models/Query/ResultsModeAvailability.swift @@ -16,18 +16,22 @@ enum ResultsModeAvailability { /// them is numeric, which is the cheaper shape. Map does not follow it, because a geometry /// column is rare: a Map segment on every result in the app would be permanent chrome for a /// pane that almost never has anything to draw. + /// + /// Output follows Map's rule for the same reason, and only a query tab offers it: a statement with no columns + /// shows its output under the success message instead, where no switcher is needed. static func modes( tabType: TabType?, hasTableName: Bool, hasColumns: Bool, - hasSpatialColumn: Bool = false + hasSpatialColumn: Bool = false, + hasServerOutput: Bool = false ) -> [ResultsViewMode] { guard let tabType else { return [] } if tabType == .table, hasTableName { return [.data, .structure, .json, .chart] + (hasSpatialColumn ? [.map] : []) } guard hasColumns else { return [] } - return [.data, .json, .chart] + (hasSpatialColumn ? [.map] : []) + return [.data, .json, .chart] + (hasSpatialColumn ? [.map] : []) + (hasServerOutput ? [.output] : []) } /// The mode a tab should be on, given what it can currently offer. @@ -57,6 +61,8 @@ extension ResultsViewMode { return String(localized: "Chart") case .map: return String(localized: "Map") + case .output: + return String(localized: "Output") } } } diff --git a/TablePro/Models/Query/StatusBarSnapshot.swift b/TablePro/Models/Query/StatusBarSnapshot.swift index 54a615d49..313a102c5 100644 --- a/TablePro/Models/Query/StatusBarSnapshot.swift +++ b/TablePro/Models/Query/StatusBarSnapshot.swift @@ -74,7 +74,8 @@ struct StatusBarSnapshot: Equatable { isFetching: Bool = false, hasStructureActions: Bool = false, isQueryPlan: Bool = false, - paginationCapability: PaginationCapability = .offset + paginationCapability: PaginationCapability = .offset, + hasServerOutput: Bool = false ) { let loaded = tableRows?.rows.count ?? 0 let displayed = displayRowCount ?? loaded @@ -93,7 +94,8 @@ struct StatusBarSnapshot: Equatable { tabType: tab?.tabType, hasTableName: tab?.tableContext.tableName != nil, hasColumns: !(tableRows?.columns.isEmpty ?? true), - hasSpatialColumn: !(tab?.display.spatialColumns.isEmpty ?? true) + hasSpatialColumn: !(tab?.display.spatialColumns.isEmpty ?? true), + hasServerOutput: hasServerOutput ), hasStructureActions: hasStructureActions, isQueryPlan: isQueryPlan, diff --git a/TablePro/Models/UI/GridSelectionOwner.swift b/TablePro/Models/UI/GridSelectionOwner.swift index 1f451fb03..a4fb7acfd 100644 --- a/TablePro/Models/UI/GridSelectionOwner.swift +++ b/TablePro/Models/UI/GridSelectionOwner.swift @@ -26,9 +26,9 @@ internal enum GridSelectionOwner: Equatable { switch resultsViewMode { case .structure: return .schemaGrid - case .chart: - /// Nothing in a chart selects a row, so the indices left over from the grid are - /// nobody's. + case .chart, .output: + /// Nothing in a chart or in printed output selects a row, so the indices left over + /// from the grid are nobody's. return .none case .data, .json, .map: break diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 1116d6ce5..aba11b359 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -182103,6 +182103,30 @@ }, "Could not read %@ from the workbook." : { + }, + "%1$@ %2$@ was created with compilation errors:" : { + + }, + "Line %1$lld, column %2$lld: %3$@" : { + + }, + "Output before the error:" : { + + }, + "The output was cut short." : { + + }, + "Copy Output" : { + + }, + "Server output" : { + + }, + "^[%lld line](inflect: true)" : { + + }, + "Only the first %lld lines are shown." : { + } }, "version" : "1.1" diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index a1de52725..e0cf68f85 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -824,12 +824,15 @@ struct MainEditorContentView: View { dataGridView(tab: tab) case let .noRows(executionTime): emptyResultView(executionTime: executionTime) - case let .statementSucceeded(rowsAffected, executionTime, statusMessage): + case let .statementSucceeded(rowsAffected, executionTime, statusMessage, serverOutput): ResultSuccessView( rowsAffected: rowsAffected, executionTime: executionTime, - statusMessage: statusMessage + statusMessage: statusMessage, + serverOutput: serverOutput ) + case let .serverOutput(output): + ServerOutputView(output: output) case let .unavailable(mode): unavailableModeView(mode) } @@ -862,6 +865,7 @@ struct MainEditorContentView: View { inputs.activeResultRowsAffected = activeResultSet?.rowsAffected ?? 0 inputs.activeResultExecutionTime = activeResultSet?.executionTime inputs.activeResultStatusMessage = activeResultSet?.statusMessage + inputs.activeResultServerOutput = activeResultSet?.serverOutput ?? .none inputs.activeResultErrorMessage = activeResultSet?.errorMessage inputs.loadedColumnCount = rows.columns.count inputs.loadedRowCount = rows.rows.count @@ -1069,7 +1073,8 @@ struct MainEditorContentView: View { isFetching: isExecuting, hasStructureActions: structureFooter.isActive, isQueryPlan: tab.display.activeExplainResult != nil, - paginationCapability: coordinator.paginationCapability + paginationCapability: coordinator.paginationCapability, + hasServerOutput: !(tab.display.activeResultSet?.serverOutput.isEmpty ?? true) ) return ResultStatusBar( model: ResultStatusModel( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 8a7803f64..e080548de 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -58,7 +58,8 @@ extension MainContentCoordinator { claim: TabExecutionClaim, isAutoLoad: Bool, trigger: TableLoadTrigger, - traceToken: TableLoadTraceToken? + traceToken: TableLoadTraceToken?, + serverOutput: PluginServerOutput = .none ) { guard tabExecution.settle(claim) else { traceStaleResultDropped(traceToken) @@ -83,7 +84,7 @@ extension MainContentCoordinator { pendingLoadTrigger = trigger return } - handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn) + handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn, serverOutput: serverOutput) reportQueryOperation( claim: claim, trigger: trigger, outcome: .failed(reason: error.localizedDescription) ) @@ -132,7 +133,8 @@ extension MainContentCoordinator { queryParameterValues: [QueryParameter]? = nil, anchor: StatementAnchor? = nil, timing: PluginQueryTiming? = nil, - viewport: GridReloadIntent = .firstRow + viewport: GridReloadIntent = .firstRow, + serverOutput: PluginServerOutput = .none ) { queryExecutionCoordinator.applyPhase1Result( tabId: tabId, @@ -152,7 +154,8 @@ extension MainContentCoordinator { queryParameterValues: queryParameterValues, anchor: anchor, timing: timing, - viewport: viewport + viewport: viewport, + serverOutput: serverOutput ) } @@ -209,13 +212,15 @@ extension MainContentCoordinator { _ error: Error, sql: String, tabId: UUID, - connection conn: DatabaseConnection + connection conn: DatabaseConnection, + serverOutput: PluginServerOutput = .none ) { queryExecutionCoordinator.handleQueryExecutionError( error, sql: sql, tabId: tabId, - connection: conn + connection: conn, + serverOutput: serverOutput ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index 73a0bdc5f..1b2bb028f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -9,6 +9,7 @@ // import Foundation +import TableProPluginKit extension MainContentCoordinator { @discardableResult @@ -75,7 +76,8 @@ extension MainContentCoordinator { tabType: tab.tabType, hasTableName: tab.tableContext.tableName != nil, hasColumns: !tableRows.columns.isEmpty, - hasSpatialColumn: !spatialColumns.isEmpty + hasSpatialColumn: !spatialColumns.isEmpty, + hasServerOutput: !(tab.display.activeResultSet?.serverOutput.isEmpty ?? true) ) let reconciled = ResultsModeAvailability.reconcile( tab.display.resultsViewMode, diff --git a/TablePro/Views/Main/MainContentCommandActions+PanelVisibility.swift b/TablePro/Views/Main/MainContentCommandActions+PanelVisibility.swift index 1a53330a9..44086fee1 100644 --- a/TablePro/Views/Main/MainContentCommandActions+PanelVisibility.swift +++ b/TablePro/Views/Main/MainContentCommandActions+PanelVisibility.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit /// Read-side of the panel toggles. Each one reads the same state its `toggle` writes, /// so the menu title describes what the command will actually do. @@ -44,7 +45,8 @@ extension MainContentCommandActions { tabType: tab.tabType, hasTableName: tab.tableContext.tableName != nil, hasColumns: !(tableRows?.columns.isEmpty ?? true), - hasSpatialColumn: !tab.display.spatialColumns.isEmpty + hasSpatialColumn: !tab.display.spatialColumns.isEmpty, + hasServerOutput: !(tab.display.activeResultSet?.serverOutput.isEmpty ?? true) ) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index d64456d18..7913e4e27 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1304,12 +1304,7 @@ final class MainContentCoordinator: ObservableObject { let rowCap = statement.rowCap let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: sql) - let needsMetadataFetch: Bool - if isEditable, let tableName { - needsMetadataFetch = !isMetadataCached(tabId: tabId, tableName: tableName) - } else { - needsMetadataFetch = false - } + let needsMetadataFetch = tableName.map { isEditable && !isMetadataCached(tabId: tabId, tableName: $0) } ?? false /// Captured now, while the result this decision was made against is still the active one. let cachedMetadata: ParsedSchemaMetadata? = needsMetadataFetch ? nil : ParsedSchemaMetadata.cached( rows: tabSessionRegistry.tableRows(for: tabId), @@ -1329,6 +1324,7 @@ final class MainContentCoordinator: ObservableObject { } let isTableTab = tab.tabType == .table + let failureOutput = ServerOutputBox() let queryTask = Task { [weak self] in guard let self else { return } @@ -1365,7 +1361,8 @@ final class MainContentCoordinator: ObservableObject { driver: driver, sql: statement.sql, parameters: nil, - rowCap: rowCap + rowCap: rowCap, + capturingOutputInto: isTableTab ? nil : failureOutput ) } let fetchEndedAt = ContinuousClock.now @@ -1422,7 +1419,8 @@ final class MainContentCoordinator: ObservableObject { isTruncated: fetchResult.isTruncated, anchor: anchor, timing: fetchResult.resolvedTiming, - viewport: viewport + viewport: viewport, + serverOutput: fetchResult.serverOutput ) scheduleTraceCompletion(traceToken, outcome: .completed) @@ -1467,7 +1465,8 @@ final class MainContentCoordinator: ObservableObject { claim: claim, isAutoLoad: isAutoLoad, trigger: trigger, - traceToken: traceToken + traceToken: traceToken, + serverOutput: failureOutput.output ) } } diff --git a/TablePro/Views/Results/ResultSuccessView.swift b/TablePro/Views/Results/ResultSuccessView.swift index 395469844..facd401a2 100644 --- a/TablePro/Views/Results/ResultSuccessView.swift +++ b/TablePro/Views/Results/ResultSuccessView.swift @@ -7,11 +7,13 @@ // import SwiftUI +import TableProPluginKit struct ResultSuccessView: View { let rowsAffected: Int let executionTime: TimeInterval? let statusMessage: String? + var serverOutput: PluginServerOutput = .none private var primaryMessage: String { if rowsAffected == 0, let status = statusMessage, !status.isEmpty { @@ -24,6 +26,37 @@ struct ResultSuccessView: View { } var body: some View { + if serverOutput.isEmpty { + summary + } else { + VStack(spacing: 0) { + compactSummary + Divider() + ServerOutputView(output: serverOutput) + } + } + } + + /// The summary on one line, so the output under it gets the height. + private var compactSummary: some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityHidden(true) + Text(primaryMessage) + if let time = executionTime { + Text(String(format: "%.3fs", time)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Spacer() + } + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var summary: some View { VStack(spacing: 16) { Spacer() Image(systemName: "checkmark.circle.fill") @@ -55,3 +88,13 @@ struct ResultSuccessView: View { ) .frame(width: 400, height: 300) } + +#Preview("With output") { + ResultSuccessView( + rowsAffected: 0, + executionTime: 0.012, + statusMessage: nil, + serverOutput: PluginServerOutput(lines: ["Hello from PL/SQL"], isTruncated: false) + ) + .frame(width: 400, height: 300) +} diff --git a/TablePro/Views/Results/ServerOutputTextView.swift b/TablePro/Views/Results/ServerOutputTextView.swift new file mode 100644 index 000000000..e9d488a1b --- /dev/null +++ b/TablePro/Views/Results/ServerOutputTextView.swift @@ -0,0 +1,33 @@ +// +// ServerOutputTextView.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// A read-only text view that holds thousands of lines of server output without laying them out as SwiftUI text. +struct ServerOutputTextView: NSViewRepresentable { + let text: String + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + guard let textView = scrollView.documentView as? NSTextView else { return scrollView } + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + textView.textColor = .labelColor + textView.backgroundColor = .textBackgroundColor + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.setAccessibilityLabel(String(localized: "Server output")) + textView.setAccessibilityIdentifier("server-output-text") + textView.string = text + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView, textView.string != text else { return } + textView.string = text + } +} diff --git a/TablePro/Views/Results/ServerOutputView.swift b/TablePro/Views/Results/ServerOutputView.swift new file mode 100644 index 000000000..069adfe25 --- /dev/null +++ b/TablePro/Views/Results/ServerOutputView.swift @@ -0,0 +1,59 @@ +// +// ServerOutputView.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +/// What a statement printed on the server, such as Oracle's `DBMS_OUTPUT`, as selectable text. +struct ServerOutputView: View { + let output: PluginServerOutput + + private var text: String { + output.lines.joined(separator: "\n") + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text("Output") + .font(.headline) + Text("^[\(output.lines.count) line](inflect: true)") + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + Spacer() + Button(String(localized: "Copy Output"), systemImage: "doc.on.doc", action: copyOutput) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help(String(localized: "Copy Output")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + Divider() + ServerOutputTextView(text: text) + if output.isTruncated { + Divider() + Text("The output was cut short.") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + } + } + + private func copyOutput() { + ClipboardService.shared.writeText(text) + } +} + +#Preview { + ServerOutputView(output: PluginServerOutput( + lines: ["Hello from PL/SQL", "", "rows processed: 42"], + isTruncated: true + )) + .frame(width: 480, height: 240) +} diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index d34b903d1..ba99bf508 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -50,6 +50,14 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen var fetchTablesError: Error? private var hangContinuation: CheckedContinuation? + var serverOutputToReturn: PluginServerOutput = .none + var fetchServerOutputCallCount = 0 + + func fetchServerOutput() async throws -> PluginServerOutput { + fetchServerOutputCallCount += 1 + return serverOutputToReturn + } + init(connection: DatabaseConnection = TestFixtures.makeConnection()) { self.connection = connection } diff --git a/TableProTests/Core/Services/Query/ServerOutputCaptureTests.swift b/TableProTests/Core/Services/Query/ServerOutputCaptureTests.swift new file mode 100644 index 000000000..e6012eef7 --- /dev/null +++ b/TableProTests/Core/Services/Query/ServerOutputCaptureTests.swift @@ -0,0 +1,89 @@ +// +// ServerOutputCaptureTests.swift +// TableProTests +// +// The server hands printed lines to whoever asks next, so output a statement left unread is reported under the one +// after it. These pin the read after every statement, the failed one included, and what a failure shows. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Server output capture") +struct ServerOutputCaptureTests { + private struct StatementFailed: Error {} + + private static let printed = PluginServerOutput(lines: ["Hello from PL/SQL", ""], isTruncated: false) + + private static func result() -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + @Test("A statement that succeeds carries what it printed") + func successCarriesTheOutput() async throws { + let driver = MockDatabaseDriver() + driver.serverOutputToReturn = Self.printed + let box = ServerOutputBox() + + let result = try await ServerOutputCapture.running(on: driver, failureOutput: box) { Self.result() } + + #expect(result.serverOutput == Self.printed) + #expect(box.output == .none) + } + + @Test("A statement that fails still has its output read, for the failure to report") + func failureStillReadsTheOutput() async { + let driver = MockDatabaseDriver() + driver.serverOutputToReturn = Self.printed + let box = ServerOutputBox() + + await #expect(throws: StatementFailed.self) { + _ = try await ServerOutputCapture.running(on: driver, failureOutput: box) { () throws -> QueryResult in + throw StatementFailed() + } + } + #expect(box.output == Self.printed) + #expect(driver.fetchServerOutputCallCount == 1) + } + + @Test("A cancelled statement sends nothing more to the server") + func cancellationReadsNothing() async { + let driver = MockDatabaseDriver() + let box = ServerOutputBox() + + await #expect(throws: CancellationError.self) { + _ = try await ServerOutputCapture.running(on: driver, failureOutput: box) { () throws -> QueryResult in + throw CancellationError() + } + } + #expect(driver.fetchServerOutputCallCount == 0) + } + + @Test("A failure lists what the statement printed before it failed") + func failureMessageListsTheOutput() { + let message = ServerOutputCapture.failureMessage( + "ORA-20001: boom", + output: PluginServerOutput(lines: ["step 1", "step 2"], isTruncated: false) + ) + #expect(message == ["ORA-20001: boom", String(localized: "Output before the error:"), "step 1", "step 2"] + .joined(separator: "\n")) + #expect(ServerOutputCapture.failureMessage("ORA-20001: boom", output: .none) == "ORA-20001: boom") + } + + /// The message is laid out as text in the error banner and sent to Fix with AI whole, so a loop that printed + /// thousands of long lines before failing must not come along with it. + @Test("A failure's message carries only the first lines, each clipped") + func failureMessageIsBounded() { + let long = String(repeating: "x", count: 5_000) + let output = PluginServerOutput(lines: Array(repeating: long, count: 1_000), isTruncated: true) + let lines = ServerOutputCapture.failureMessage("ORA-20001: boom", output: output) + .components(separatedBy: "\n") + + #expect(lines.count == 2 + ServerOutputCapture.failureLineLimit + 1) + #expect(lines.dropFirst(2).prefix(ServerOutputCapture.failureLineLimit).allSatisfy { + ($0 as NSString).length == ServerOutputCapture.failureLineLength + 1 + }) + } +} diff --git a/TableProTests/Models/Query/QueryResultPresentationTests.swift b/TableProTests/Models/Query/QueryResultPresentationTests.swift index 1a09dfe53..4b9660e83 100644 --- a/TableProTests/Models/Query/QueryResultPresentationTests.swift +++ b/TableProTests/Models/Query/QueryResultPresentationTests.swift @@ -10,6 +10,7 @@ import Foundation @testable import TablePro +import TableProPluginKit import Testing @Suite("QueryResultPresentation") @@ -95,10 +96,58 @@ struct QueryResultPresentationTests { #expect(QueryResultPresentation(inputs: inputs).content == .statementSucceeded( rowsAffected: 7, executionTime: 0.1, - statusMessage: "OK" + statusMessage: "OK", + serverOutput: .none )) } + @Test("A block that printed shows its output with the success view") + func statementSucceededCarriesItsOutput() { + let output = PluginServerOutput(lines: ["Hello from PL/SQL"], isTruncated: false) + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.hasActiveResultSet = true + inputs.activeResultHasColumns = false + inputs.activeResultServerOutput = output + + #expect(QueryResultPresentation(inputs: inputs).content == .statementSucceeded( + rowsAffected: 0, + executionTime: nil, + statusMessage: nil, + serverOutput: output + )) + } + + @Test("Output mode shows what a query that returned rows printed") + func outputModeShowsTheOutput() { + let output = PluginServerOutput(lines: ["row printed"], isTruncated: false) + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.hasActiveResultSet = true + inputs.activeResultHasColumns = true + inputs.loadedColumnCount = 1 + inputs.loadedRowCount = 1 + inputs.viewMode = .output + inputs.activeResultServerOutput = output + + #expect(QueryResultPresentation(inputs: inputs).content == .serverOutput(output)) + } + + /// Switching to a result that printed nothing leaves the tab in Output mode until the next install reconciles + /// it, and the pane must not go blank in the meantime. + @Test("Output mode over a result that printed nothing shows the grid") + func outputModeWithoutOutputFallsBackToTheGrid() { + var inputs = QueryResultInputs() + inputs.hasExecuted = true + inputs.hasActiveResultSet = true + inputs.activeResultHasColumns = true + inputs.loadedColumnCount = 1 + inputs.loadedRowCount = 1 + inputs.viewMode = .output + + #expect(QueryResultPresentation(inputs: inputs).content == .grid) + } + @Test("A failed execution shows the banner and does not claim success") func failedExecution() { var inputs = QueryResultInputs() diff --git a/TableProTests/Models/Query/TabDisplayOutputModeTests.swift b/TableProTests/Models/Query/TabDisplayOutputModeTests.swift new file mode 100644 index 000000000..ac6929473 --- /dev/null +++ b/TableProTests/Models/Query/TabDisplayOutputModeTests.swift @@ -0,0 +1,54 @@ +// +// TabDisplayOutputModeTests.swift +// TableProTests +// +// Output mode shows what the active result printed. A result's rows, and the modes they allow, are installed +// before the result replaces the old one, so the mode has to be settled when the result itself arrives. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("Tab display - Output mode") +struct TabDisplayOutputModeTests { + private static func result(printing lines: [String]) -> ResultSet { + let result = ResultSet(label: "Result") + result.serverOutput = PluginServerOutput(lines: lines, isTruncated: false) + return result + } + + @Test("A new result that printed nothing takes the tab out of Output mode") + func leavesOutputModeForAResultWithoutOutput() { + var display = TabDisplayState() + display.replaceUnpinnedResults(with: [Self.result(printing: ["first"])]) + display.resultsViewMode = .output + + display.replaceUnpinnedResults(with: [Self.result(printing: [])]) + + #expect(display.resultsViewMode == .data) + } + + @Test("A new result that printed keeps the tab in Output mode") + func keepsOutputModeForAResultWithOutput() { + var display = TabDisplayState() + display.replaceUnpinnedResults(with: [Self.result(printing: ["first"])]) + display.resultsViewMode = .output + + display.replaceUnpinnedResults(with: [Self.result(printing: ["second"])]) + + #expect(display.resultsViewMode == .output) + } + + @Test("Other modes are left alone") + func otherModesAreUntouched() { + var display = TabDisplayState() + display.resultsViewMode = .chart + + display.replaceUnpinnedResults(with: [Self.result(printing: [])]) + + #expect(display.resultsViewMode == .chart) + } +} diff --git a/TableProTests/Models/ResultStatusModelTests.swift b/TableProTests/Models/ResultStatusModelTests.swift index 19dd2ea57..1292ea165 100644 --- a/TableProTests/Models/ResultStatusModelTests.swift +++ b/TableProTests/Models/ResultStatusModelTests.swift @@ -426,6 +426,28 @@ struct ResultsModeAvailabilityTests { #expect(modes == [.data, .structure, .json, .chart]) } + @Test("Output is offered only for a query result that printed something") + func outputFollowsTheServerOutput() { + #expect(ResultsModeAvailability.modes( + tabType: .query, + hasTableName: false, + hasColumns: true, + hasServerOutput: true + ) == [.data, .json, .chart, .output]) + #expect(ResultsModeAvailability.modes( + tabType: .query, + hasTableName: false, + hasColumns: false, + hasServerOutput: true + ).isEmpty) + #expect(ResultsModeAvailability.modes( + tabType: .table, + hasTableName: true, + hasColumns: true, + hasServerOutput: true + ) == [.data, .structure, .json, .chart]) + } + @Test("A query result has no structure to show") func queryTabHasNoStructure() { let modes = ResultsModeAvailability.modes(tabType: .query, hasTableName: false, hasColumns: true) @@ -496,5 +518,4 @@ struct ResultsModeAvailabilityTests { } #expect(ResultsViewMode.json.displayName == "JSON") } - } diff --git a/TableProTests/Models/UI/GridSelectionOwnerTests.swift b/TableProTests/Models/UI/GridSelectionOwnerTests.swift index 17363aeb4..33aa1a3ce 100644 --- a/TableProTests/Models/UI/GridSelectionOwnerTests.swift +++ b/TableProTests/Models/UI/GridSelectionOwnerTests.swift @@ -31,14 +31,17 @@ struct GridSelectionOwnerTests { } /// Row editing follows the owner, so this is also the list of modes whose row commands stay - /// live. JSON shows the same rows the data grid owns and keeps them; Chart has no rows to edit. + /// live. JSON shows the same rows the data grid owns and keeps them; Chart and Output have no + /// rows to edit. @Test("Only a mode with an owning grid can edit rows") func rowEditingFollowsTheOwningGrid() { let owners = ResultsViewMode.allCases.map { GridSelectionOwner.resolve(tabType: .table, resultsViewMode: $0) } - #expect(owners == [.dataGrid, .schemaGrid, .dataGrid, GridSelectionOwner.none, .dataGrid]) + #expect(owners == [ + .dataGrid, .schemaGrid, .dataGrid, GridSelectionOwner.none, .dataGrid, GridSelectionOwner.none, + ]) } /// Map writes into the shared channel itself: clicking a shape selects that row. The indices diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 9295c3717..67e69e5c3 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -96,6 +96,8 @@ Scripts written for SQL*Plus or SQL Developer run unchanged: a line holding only A `CREATE` whose unit does not compile fails with Oracle's own errors, one per line with its position, instead of reporting success. The unit stays in the schema as INVALID until a version that compiles replaces it. +`DBMS_OUTPUT.PUT_LINE` lines show with the result of the statement that printed them. See [Server output](/features/query-results#server-output). + In a trigger body, `:NEW` and `:OLD` are left as written. In an anonymous block, `:name` is a [query parameter](/features/query-parameters) filled from the panel. An anonymous block, or a query whose `WITH` clause declares a function, runs code on the server. One that drops or truncates, including through `EXECUTE IMMEDIATE '…'`, raises the [dangerous query warning](/features/safe-mode) before it runs, and a Read-Only connection refuses it like any write. [MCP clients](/external-api/mcp-tools) and the AI assistant cannot send either. @@ -132,8 +134,7 @@ The Oracle driver is compiled into TablePro Mobile, with no plugin to install. B - No OS auth, wallets, Kerberos, or LDAP. Create a database user with a password and connect as that. - BFILE columns show the locator, never the file. There is no path to the contents from the app. -- SQL*Plus commands such as `SET SERVEROUTPUT ON`, `SHOW ERRORS` and `EXEC` are not SQL and fail when sent. Delete them, and write `EXEC p` as `BEGIN p; END;`. -- `DBMS_OUTPUT` lines are not shown. Return the value from a query instead, or write it to a table. +- SQL*Plus commands such as `SET SERVEROUTPUT ON`, `SHOW ERRORS` and `EXEC` are not SQL and fail when sent. Delete them, and write `EXEC p` as `BEGIN p; END;`. Output needs no `SET SERVEROUTPUT ON`. - No Users & Roles pane. Manage accounts with `CREATE USER` and `GRANT` in the editor. ## Troubleshooting diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 4e16cf072..e3c3857e1 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -161,7 +161,7 @@ Every copy follows the grid as shown: hidden columns are left out, columns keep ## View modes -Switch between **Data**, **Structure**, **JSON**, **Chart**, and **Map** with the switcher at the leading edge of the status bar, or from **View > Result View**. Query tabs have no Structure mode, Map appears only while the result holds a column of drawable geometry, the mode is remembered per tab, and [Cell and Row Viewers](/features/json-viewer) covers JSON mode. +Switch between **Data**, **Structure**, **JSON**, **Chart**, **Map**, and **Output** with the switcher at the leading edge of the status bar, or from **View > Result View**. Query tabs have no Structure mode, Map appears only while the result holds a column of drawable geometry, Output appears only when the statement printed something on the server (see [Server output](/features/query-results#server-output)), the mode is remembered per tab, and [Cell and Row Viewers](/features/json-viewer) covers JSON mode. ### Chart mode diff --git a/docs/features/query-results.mdx b/docs/features/query-results.mdx index be4c055e2..56679bb0e 100644 --- a/docs/features/query-results.mdx +++ b/docs/features/query-results.mdx @@ -70,4 +70,16 @@ When the cap trims a result the status bar reads **Showing N rows** and offers * INSERT, UPDATE, DELETE and DDL show a success view with the affected row count and the execution time. +## Server output + +Lines a statement prints on the server show with that statement's result. On Oracle that is `DBMS_OUTPUT.PUT_LINE`, and the output is on from the moment you connect: no `SET SERVEROUTPUT ON`. + +- A block or procedure call that returns no rows shows its lines under the success message. +- A query that returned rows and also printed gains an **Output** mode beside **Data**. +- A statement that fails lists the first 20 lines it printed before the error, inside the error banner. + +The lines are read after every statement run from the editor, so each result holds only what its own statement printed. At most 10,000 lines are kept per statement; past that the result reads "The output was cut short." and the rest is discarded. Lines a trigger prints while a grid edit is saved are not read then, and show with the next statement you run from the editor. + +## Failed statements + A failed statement shows a red banner above the results with the database's own error message and a **Fix with AI** button. See [AI Assistant](/features/ai-assistant). A control character or zero-width space in the message shows as its short name in angle brackets, such as ``; see [Invisible characters](/features/sql-editor#invisible-characters).