Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
<<each_line>>
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)
}
}
Original file line number Diff line number Diff line change
@@ -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;"))
}
}
20 changes: 20 additions & 0 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {}
Expand Down
20 changes: 20 additions & 0 deletions Plugins/TableProPluginKit/PluginServerOutput.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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)"
)
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading