diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index 1b3b85a3..1f19fd00 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -39,12 +39,39 @@ struct OracleQueryResult { let isTruncated: Bool } +// MARK: - Query Serialization + +/// OracleNIO does not support concurrent queries on a single connection. +/// Sending a second statement while the first stream is active corrupts the +/// state machine. This actor serializes all executeQuery calls. +private actor QueryGate { + private var busy = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !busy { + busy = true + return + } + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + if !waiters.isEmpty { + waiters.removeFirst().resume() + } else { + busy = false + } + } +} + // MARK: - Connection Class final class OracleConnectionWrapper: @unchecked Sendable { // MARK: - Properties private static let connectionCounter = OSAllocatedUnfairLock(initialState: 0) + private let queryGate = QueryGate() private let host: String private let port: Int @@ -143,6 +170,10 @@ final class OracleConnectionWrapper: @unchecked Sendable { } lock.unlock() + // OracleNIO does not support concurrent queries on a single connection. + // Serialize all queries to prevent state-machine corruption. + await queryGate.acquire() + do { let statement = OracleStatement(stringLiteral: query) let stream = try await connection.execute(statement, logger: nioLogger) @@ -183,6 +214,7 @@ final class OracleConnectionWrapper: @unchecked Sendable { columnTypeNames = Array(repeating: "unknown", count: columns.count) } + await queryGate.release() return OracleQueryResult( columns: columns, columnTypeNames: columnTypeNames, @@ -192,12 +224,16 @@ final class OracleConnectionWrapper: @unchecked Sendable { ) } catch let sqlError as OracleSQLError { let detail = sqlError.serverInfo?.message ?? sqlError.description + await queryGate.release() throw OracleError(message: detail) } catch let error as OracleError { + await queryGate.release() throw error } catch is CancellationError { + await queryGate.release() throw CancellationError() } catch { + await queryGate.release() throw OracleError(message: "Query execution failed: \(String(describing: error))") } } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 35432d50..8180d1c7 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -401,7 +401,6 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { c.DATA_PRECISION, c.DATA_SCALE, c.NULLABLE, - c.DATA_DEFAULT, CASE WHEN cc.COLUMN_NAME IS NOT NULL THEN 'Y' ELSE 'N' END AS IS_PK FROM ALL_TAB_COLUMNS c LEFT JOIN ( @@ -424,8 +423,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let precision = row[safe: 4] ?? nil let scale = row[safe: 5] ?? nil let isNullable = (row[safe: 6] ?? nil) == "Y" - let defaultValue = (row[safe: 7] ?? nil)?.trimmingCharacters(in: .whitespacesAndNewlines) - let isPk = (row[safe: 8] ?? nil) == "Y" + let isPk = (row[safe: 7] ?? nil) == "Y" let fullType = buildOracleFullType(dataType: dataType, dataLength: dataLength, precision: precision, scale: scale) @@ -434,7 +432,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { dataType: fullType, isNullable: isNullable, isPrimaryKey: isPk, - defaultValue: defaultValue + defaultValue: nil ) columnsByTable[tableName, default: []].append(col) } @@ -509,15 +507,10 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTableDDL(table: String, schema: String?) async throws -> String { let escapedTable = table.replacingOccurrences(of: "'", with: "''") let escaped = effectiveSchemaEscaped(schema) - let sql = "SELECT DBMS_METADATA.GET_DDL('TABLE', '\(escapedTable)', '\(escaped)') FROM DUAL" - do { - let result = try await execute(query: sql) - if let row = result.rows.first, let ddl = row.first ?? nil { - return ddl - } - } catch { - Self.logger.debug("DBMS_METADATA failed, building DDL manually: \(error.localizedDescription)") - } + + // Do NOT use DBMS_METADATA.GET_DDL — if the object type is wrong + // (view, materialized view, etc.), Oracle returns ORA-31603 which + // corrupts OracleNIO's connection state machine. Build DDL manually. let cols = try await fetchColumns(table: table, schema: schema) var ddl = "CREATE TABLE \"\(escaped)\".\"\(escapedTable)\" (\n" @@ -535,9 +528,10 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchViewDefinition(view: String, schema: String?) async throws -> String { let escapedView = view.replacingOccurrences(of: "'", with: "''") let escaped = effectiveSchemaEscaped(schema) - // Use DBMS_METADATA.GET_DDL instead of ALL_VIEWS.TEXT to avoid LONG column type - // that crashes OracleNIO's decoder - let sql = "SELECT DBMS_METADATA.GET_DDL('VIEW', '\(escapedView)', '\(escaped)') FROM DUAL" + // ALL_VIEWS.TEXT is LONG (crashes OracleNIO). TEXT_VC is VARCHAR2(4000), safe. + // Do NOT use DBMS_METADATA.GET_DDL — wrong object type triggers ORA-31603 + // which corrupts OracleNIO's connection state machine. + let sql = "SELECT TEXT_VC FROM ALL_VIEWS WHERE VIEW_NAME = '\(escapedView)' AND OWNER = '\(escaped)'" let result = try await execute(query: sql) return result.rows.first?.first?.flatMap { $0 } ?? "" }