Skip to content

Commit 8e16baa

Browse files
vkuttypCopilot
andcommitted
Remove stale .gitignore entries for moved directories
PosBackend, cosmo, and cosmo-swift have been moved to CustomTests/. Remove their now-stale ignore rules from the Swift repo .gitignore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9f78d27 commit 8e16baa

4 files changed

Lines changed: 145 additions & 48 deletions

File tree

.gitignore

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,6 @@ xcuserdata/
77
DerivedData/
88
.DS_Store
99

10-
# PosBackend Vapor app (separate project)
11-
PosBackend/
12-
1310
# Downloaded CI logs
1411
logs_*/
1512
frpc.toml
16-
17-
# Local scratch executables (not part of the published package)
18-
cosmo-swift/
19-
cosmo/

Sources/CosmoMSSQL/MSSQLConnection.swift

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ public final class MSSQLConnection: SQLDatabase, @unchecked Sendable {
197197
private var isClosed: Bool = false
198198
/// True when the connection is still open and usable.
199199
public var isOpen: Bool { !isClosed }
200-
private var msgReader: MessageReader? // AsyncThrowingStream-based; no eventLoop hop per read
200+
private var msgReader: TDSFrameReader? // AsyncThrowingStream-based; no eventLoop hop per read
201201
/// Tracks whether we are inside an explicit transaction (BEGIN TRANSACTION).
202202
private var inTransaction: Bool = false
203203
/// Current transaction descriptor — updated from ENVCHANGE type 8/9/10 responses.
@@ -240,15 +240,15 @@ public final class MSSQLConnection: SQLDatabase, @unchecked Sendable {
240240

241241
private func handshake(sslContext: NIOSSLContext? = nil) async throws {
242242
// 1. Add pipeline: TDSTLSFramer (pass-through initially) + framing + bridge
243-
let bridge = AsyncStreamBridge()
243+
let bridge = TDSFrameBridge()
244244
// Swift 6: ByteToMessageHandler has Sendable marked unavailable (event-loop-bound).
245245
let bridgeBox = _UnsafeSendable(bridge)
246246
let frameBox = _UnsafeSendable(ByteToMessageHandler(TDSFramingHandler()))
247247
let framer = tlsFramer
248248
try await channel.eventLoop.submit {
249249
try self.channel.pipeline.syncOperations.addHandlers([framer, frameBox.value, bridgeBox.value])
250250
}.get()
251-
msgReader = MessageReader(bridge)
251+
msgReader = TDSFrameReader(bridge)
252252

253253
// 2. Pre-Login — negotiate encryption preference
254254
let preLoginResp = try await sendPreLogin()
@@ -428,23 +428,53 @@ public final class MSSQLConnection: SQLDatabase, @unchecked Sendable {
428428

429429
/// Stream rows one-by-one as they are decoded from the TDS response.
430430
///
431-
/// The full TDS message is received before the first row is yielded (TDS framing
432-
/// assembles the complete response), but the caller can process rows without
433-
/// buffering the entire result set as an array.
431+
/// Rows are yielded incrementally as TDS packets arrive — without buffering
432+
/// the entire result set.
434433
public func queryStream(_ sql: String, _ binds: [SQLValue] = []) -> AsyncThrowingStream<SQLRow, Error> {
435434
AsyncThrowingStream { cont in
436435
Task { [self] in
437436
do {
438437
guard !self.isClosed else { throw SQLError.connectionClosed }
439-
let dec: TDSTokenDecoder
438+
439+
// Send the query
440440
if binds.isEmpty {
441-
dec = try await self.runBatchDecoder(sql)
441+
var payload = encodeSQLBatch(sql: sql)
442+
sendPacket(type: .sqlBatch, payload: &payload)
442443
} else {
443-
dec = try await self.runRPCDecoder(Self.convertPlaceholders(sql), binds: binds)
444+
let rpc = TDSRPCRequest(sql: Self.convertPlaceholders(sql), binds: binds)
445+
var payload = rpc.encode(allocator: channel.allocator)
446+
sendPacket(type: .rpc, payload: &payload)
444447
}
445-
for row in dec.rows {
446-
cont.yield(row)
448+
449+
var dec = TDSTokenDecoder()
450+
var remainder: ByteBuffer? = nil
451+
452+
outerLoop: while true {
453+
guard let frame = try await msgReader!.next() else {
454+
throw SQLError.connectionClosed
455+
}
456+
457+
// Append this packet to any leftover bytes from the previous decode
458+
if remainder == nil || remainder!.readableBytes == 0 {
459+
remainder = frame.payload
460+
} else {
461+
var combined = channel.allocator.buffer(
462+
capacity: remainder!.readableBytes + frame.payload.readableBytes)
463+
combined.writeImmutableBuffer(remainder!)
464+
combined.writeImmutableBuffer(frame.payload)
465+
remainder = combined
466+
}
467+
468+
// Decode as many complete tokens as possible
469+
let rows = dec.decodePartial(buffer: &remainder!)
470+
for row in rows { cont.yield(row) }
471+
472+
if frame.isEOM { break outerLoop }
447473
}
474+
475+
if let err = dec.serverError { throw err }
476+
if let td = dec.transactionDescriptor { transactionDescriptor = td }
477+
dispatchInfoMessages(dec)
448478
cont.finish()
449479
} catch {
450480
cont.finish(throwing: error)
@@ -758,10 +788,8 @@ public final class MSSQLConnection: SQLDatabase, @unchecked Sendable {
758788

759789
/// Receive one complete TDS message via the async stream bridge handler.
760790
private func receivePacket() async throws -> ByteBuffer {
761-
guard let reader = msgReader, let buf = try await reader.next() else {
762-
throw SQLError.connectionClosed
763-
}
764-
return buf
791+
guard let reader = msgReader else { throw SQLError.connectionClosed }
792+
return try await reader.receiveMessage()
765793
}
766794

767795
// MARK: - SQL Batch encoding

Sources/CosmoMSSQL/TDS/TDSDecoder.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,34 @@ struct TDSTokenDecoder {
5555
}
5656
}
5757

58+
/// Incrementally decode tokens from a partial buffer.
59+
/// Returns rows decoded in this call. Leaves the reader index at the first
60+
/// byte of any incomplete token so the caller can prepend more data and retry.
61+
mutating func decodePartial(buffer: inout ByteBuffer) -> [SQLRow] {
62+
var newRows: [SQLRow] = []
63+
while buffer.readableBytes > 0 {
64+
let savedIndex = buffer.readerIndex
65+
guard let tokenByte: UInt8 = buffer.readInteger() else { break }
66+
guard let token = TDSTokenType(rawValue: tokenByte) else {
67+
buffer.moveReaderIndex(to: savedIndex)
68+
break
69+
}
70+
let countBefore = currentRows.count
71+
do {
72+
try decodeToken(token, buffer: &buffer)
73+
} catch {
74+
// Incomplete — rewind to before the token byte and stop
75+
buffer.moveReaderIndex(to: savedIndex)
76+
break
77+
}
78+
let countAfter = currentRows.count
79+
if countAfter > countBefore {
80+
newRows.append(contentsOf: currentRows[countBefore..<countAfter])
81+
}
82+
}
83+
return newRows
84+
}
85+
5886
// MARK: - Token dispatch
5987

6088
private mutating func decodeToken(_ token: TDSTokenType, buffer: inout ByteBuffer) throws {

Sources/CosmoMSSQL/TDS/TDSHandler.swift

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,27 @@ import CosmoSQLCore
33

44
// ── TDS NIO Channel Handler ───────────────────────────────────────────────────
55
//
6-
// Reassembles TDS packets (which may be split across TCP segments or
7-
// span multiple NIO ByteBuffers) and emits complete TDS messages.
6+
// Emits one TDSFrame per TDS packet so consumers can process rows incrementally
7+
// without waiting for the entire EOM-terminated message to arrive.
88

9-
final class TDSFramingHandler: ByteToMessageDecoder {
10-
typealias InboundOut = ByteBuffer
9+
/// A single TDS packet payload emitted by TDSFramingHandler.
10+
struct TDSFrame: @unchecked Sendable {
11+
var payload: ByteBuffer
12+
var isEOM: Bool // true if this is the last packet of the TDS message
13+
}
1114

12-
private var pendingPayload: ByteBuffer?
13-
private var expectedTotal: Int = 0
14-
private var isComplete: Bool = false
15+
final class TDSFramingHandler: ByteToMessageDecoder {
16+
typealias InboundOut = TDSFrame
1517

1618
func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
17-
// We need at least an 8-byte header
1819
guard buffer.readableBytes >= TDSPacketHeader.size else { return .needMoreData }
19-
20-
// Peek at header without consuming
2120
var peek = buffer
2221
let header = try TDSPacketHeader.decode(from: &peek)
23-
2422
let packetLen = Int(header.length)
2523
guard buffer.readableBytes >= packetLen else { return .needMoreData }
26-
27-
// Consume the full packet
2824
var packet = buffer.readSlice(length: packetLen)!
29-
packet.moveReaderIndex(forwardBy: TDSPacketHeader.size) // skip header
30-
31-
if pendingPayload == nil {
32-
pendingPayload = context.channel.allocator.buffer(capacity: packetLen)
33-
}
34-
pendingPayload!.writeBuffer(&packet)
35-
36-
if header.status == .eom {
37-
let msg = pendingPayload!
38-
pendingPayload = nil
39-
context.fireChannelRead(wrapInboundOut(msg))
40-
}
41-
25+
packet.moveReaderIndex(forwardBy: TDSPacketHeader.size) // strip 8-byte header
26+
context.fireChannelRead(wrapInboundOut(TDSFrame(payload: packet, isEOM: header.status == .eom)))
4227
return .continue
4328
}
4429

@@ -49,6 +34,69 @@ final class TDSFramingHandler: ByteToMessageDecoder {
4934
}
5035
}
5136

37+
// ── TDSFrameBridge ────────────────────────────────────────────────────────────
38+
//
39+
// Bridges per-packet TDSFrame values from the NIO pipeline into an
40+
// AsyncThrowingStream for consumption by async Swift callers.
41+
42+
final class TDSFrameBridge: ChannelInboundHandler, @unchecked Sendable {
43+
typealias InboundIn = TDSFrame
44+
45+
private let cont: AsyncThrowingStream<TDSFrame, any Error>.Continuation
46+
let stream: AsyncThrowingStream<TDSFrame, any Error>
47+
48+
init() {
49+
var captured: AsyncThrowingStream<TDSFrame, any Error>.Continuation!
50+
stream = AsyncThrowingStream(bufferingPolicy: .unbounded) { captured = $0 }
51+
cont = captured
52+
}
53+
54+
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
55+
cont.yield(unwrapInboundIn(data))
56+
}
57+
58+
func errorCaught(context: ChannelHandlerContext, error: any Error) {
59+
cont.finish(throwing: error)
60+
context.fireErrorCaught(error)
61+
}
62+
63+
func channelInactive(context: ChannelHandlerContext) {
64+
cont.finish()
65+
context.fireChannelInactive()
66+
}
67+
}
68+
69+
// ── TDSFrameReader ────────────────────────────────────────────────────────────
70+
//
71+
// Wraps AsyncThrowingStream.AsyncIterator (a struct) in a class so it can be
72+
// stored and advanced from async contexts.
73+
74+
final class TDSFrameReader: @unchecked Sendable {
75+
private var iterator: AsyncThrowingStream<TDSFrame, any Error>.AsyncIterator
76+
77+
init(_ bridge: TDSFrameBridge) {
78+
iterator = bridge.stream.makeAsyncIterator()
79+
}
80+
81+
func next() async throws -> TDSFrame? {
82+
try await iterator.next()
83+
}
84+
85+
/// Accumulate frames until EOM — used by non-streaming callers.
86+
func receiveMessage() async throws -> ByteBuffer {
87+
var accumulated: ByteBuffer? = nil
88+
while true {
89+
guard let frame = try await next() else { throw SQLError.connectionClosed }
90+
if accumulated == nil {
91+
accumulated = frame.payload
92+
} else {
93+
accumulated!.writeImmutableBuffer(frame.payload)
94+
}
95+
if frame.isEOM { return accumulated! }
96+
}
97+
}
98+
}
99+
52100
// MARK: - TDS Packet Encoder
53101

54102
final class TDSPacketEncoder: MessageToByteEncoder {

0 commit comments

Comments
 (0)