Skip to content

Commit facc307

Browse files
vkuttypCopilot
andcommitted
Port C# @name parameter fixes to Swift MySQL and Postgres drivers
MySQL (MySQLConnection.swift): - Add query(_:params:[String:SQLValue]) overload with @name placeholder support - Add execute(_:params:[String:SQLValue]) overload with @name placeholder support - Add renderQueryNamed: scans SQL for @Identifier, inlines escaped literals (text protocol — values are safely escaped via mysqlLiteral before sending) MySQL (MySQLDecoder.swift): - Fix DATETIME/TIMESTAMP parsing to handle fractional seconds (microseconds) e.g. '2026-03-03 23:07:01.063905' — adds _mysqlDateTimeFracFmt fallback Equivalent to C# ParseBinaryDateTime which already handled fractional precision Postgres (PostgresConnection.swift): - Add query(_:params:[String:SQLValue]) overload with @name placeholder support - Add execute(_:params:[String:SQLValue]) overload with @name placeholder support - Add renderQueryNamed: scans SQL for @Identifier, inlines escaped literals (simple query protocol — values are safely escaped via pgLiteral before sending) Note: Swift drivers use text/simple-query protocol (not prepared statements), so C# bugs 1-3 (null handler, CLIENT_DEPRECATE_EOF, binary protocol rows) do not apply. The @name param translation mirrors C#'s TranslateParams fix but works at a higher level: inline literal substitution rather than placeholder rewriting for a wire protocol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 960a57d commit facc307

File tree

3 files changed

+154
-1
lines changed

3 files changed

+154
-1
lines changed

Sources/CosmoMySQL/MySQLConnection.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,75 @@ public final class MySQLConnection: SQLDatabase, @unchecked Sendable {
430430
try await channel.close().get()
431431
}
432432

433+
// MARK: - Named parameter API (@name style)
434+
435+
/// Execute a query with named parameters.
436+
///
437+
/// Use `@name` placeholders in SQL; pass values as a dictionary.
438+
/// Values are inlined as escaped literals (text protocol), so this is safe for
439+
/// all backends without prepared-statement support.
440+
///
441+
/// Example:
442+
/// ```swift
443+
/// let rows = try await conn.query(
444+
/// "SELECT * FROM credentials WHERE accesskey = @ak",
445+
/// params: ["ak": .string("mykey")])
446+
/// ```
447+
public func query(_ sql: String, params: [String: SQLValue]) async throws -> [SQLRow] {
448+
guard !isClosed else { throw SQLError.connectionClosed }
449+
let rendered = renderQueryNamed(sql, params: params)
450+
logger.debug("MySQL query: \(rendered)")
451+
try await sendQuery(rendered)
452+
return try await readResultSet()
453+
}
454+
455+
/// Execute a DML statement with named parameters and return affected row count.
456+
public func execute(_ sql: String, params: [String: SQLValue]) async throws -> Int {
457+
guard !isClosed else { throw SQLError.connectionClosed }
458+
let rendered = renderQueryNamed(sql, params: params)
459+
logger.debug("MySQL execute: \(rendered)")
460+
try await sendQuery(rendered)
461+
var packet = try await receivePacket()
462+
let resp = try MySQLResponse.decode(packet: &packet, capabilities: capabilities)
463+
switch resp {
464+
case .ok(let affected, _, _, _):
465+
return Int(affected)
466+
case .err(let code, _, let message):
467+
throw SQLError.serverError(code: Int(code), message: message)
468+
default:
469+
return 0
470+
}
471+
}
472+
473+
/// Translates `@name` placeholders to inlined MySQL literals.
474+
/// Scans the SQL string, replacing each `@identifier` with the escaped literal
475+
/// for the matching key in `params`. Unknown names are left unchanged.
476+
private func renderQueryNamed(_ sql: String, params: [String: SQLValue]) -> String {
477+
var result = ""
478+
var i = sql.startIndex
479+
while i < sql.endIndex {
480+
guard sql[i] == "@" else { result.append(sql[i]); i = sql.index(after: i); continue }
481+
let afterAt = sql.index(after: i)
482+
// Must be followed by a letter or underscore to be an identifier
483+
guard afterAt < sql.endIndex, sql[afterAt].isLetter || sql[afterAt] == "_" else {
484+
result.append(sql[i]); i = sql.index(after: i); continue
485+
}
486+
// Read the full identifier
487+
var end = afterAt
488+
while end < sql.endIndex && (sql[end].isLetter || sql[end].isNumber || sql[end] == "_") {
489+
end = sql.index(after: end)
490+
}
491+
let name = String(sql[afterAt..<end])
492+
if let value = params[name] {
493+
result += value.mysqlLiteral
494+
} else {
495+
result += "@"; result += name // leave unknown names unchanged
496+
}
497+
i = end
498+
}
499+
return result
500+
}
501+
433502
// MARK: - COM_QUERY
434503

435504
private func sendQuery(_ sql: String) async throws {

Sources/CosmoMySQL/Protocol/MySQLDecoder.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@ private let _mysqlDateTimeFmt: DateFormatter = {
193193
let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX")
194194
f.dateFormat = "yyyy-MM-dd HH:mm:ss"; return f
195195
}()
196+
// MySQL can return microseconds: "2026-03-03 23:07:01.063905"
197+
private let _mysqlDateTimeFracFmt: DateFormatter = {
198+
let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX")
199+
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"; return f
200+
}()
196201

197202
func mysqlDecode(columnType: UInt8, isUnsigned: Bool, text: String?) -> SQLValue {
198203
guard let text = text else { return .null }
@@ -219,7 +224,9 @@ func mysqlDecode(columnType: UInt8, isUnsigned: Bool, text: String?) -> SQLValue
219224
case 0x0A: // DATE
220225
return _mysqlDateFmt.date(from: text).map { .date($0) } ?? .string(text)
221226
case 0x0B, 0x0C, 0x07: // TIME, DATETIME, TIMESTAMP
222-
return _mysqlDateTimeFmt.date(from: text).map { .date($0) } ?? .string(text)
227+
// Try without fractional seconds first (most common), then with microseconds
228+
let date = _mysqlDateTimeFmt.date(from: text) ?? _mysqlDateTimeFracFmt.date(from: text)
229+
return date.map { .date($0) } ?? .string(text)
223230
default:
224231
return .string(text)
225232
}

Sources/CosmoPostgres/PostgresConnection.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,83 @@ public final class PostgresConnection: SQLDatabase, @unchecked Sendable {
517517
try await channel.close().get()
518518
}
519519

520+
// MARK: - Named parameter API (@name style)
521+
522+
/// Execute a query with named parameters.
523+
///
524+
/// Use `@name` placeholders in SQL; pass values as a dictionary.
525+
/// Values are inlined as escaped literals (simple query protocol).
526+
///
527+
/// Example:
528+
/// ```swift
529+
/// let rows = try await conn.query(
530+
/// "SELECT * FROM s3.credentials WHERE accesskey = @ak",
531+
/// params: ["ak": .string("mykey")])
532+
/// ```
533+
public func query(_ sql: String, params: [String: SQLValue]) async throws -> [SQLRow] {
534+
guard !isClosed else { throw SQLError.connectionClosed }
535+
let rendered = renderQueryNamed(sql, params: params)
536+
logger.debug("PostgreSQL query: \(rendered)")
537+
let msg = PGFrontend.query(rendered, allocator: channel.allocator)
538+
send(msg)
539+
return try await collectResults()
540+
}
541+
542+
/// Execute a DML statement with named parameters and return affected row count.
543+
public func execute(_ sql: String, params: [String: SQLValue]) async throws -> Int {
544+
guard !isClosed else { throw SQLError.connectionClosed }
545+
let rendered = renderQueryNamed(sql, params: params)
546+
logger.debug("PostgreSQL execute: \(rendered)")
547+
let msg = PGFrontend.query(rendered, allocator: channel.allocator)
548+
send(msg)
549+
var rowsAffected = 0
550+
var pendingError: (any Error)?
551+
loop: while true {
552+
let m = try await receiveMessage()
553+
switch m {
554+
case .commandComplete(let tag):
555+
rowsAffected = tag.rowsAffected
556+
case .readyForQuery:
557+
break loop
558+
case .error(_, _, let message):
559+
pendingError = SQLError.serverError(code: 0, message: message)
560+
case .notice(let msg):
561+
onNotice?(msg)
562+
default:
563+
break
564+
}
565+
}
566+
if let err = pendingError { throw err }
567+
return rowsAffected
568+
}
569+
570+
/// Translates `@name` placeholders to inlined PostgreSQL literals.
571+
/// Scans the SQL string, replacing each `@identifier` with the escaped literal
572+
/// for the matching key in `params`. Unknown names are left unchanged.
573+
private func renderQueryNamed(_ sql: String, params: [String: SQLValue]) -> String {
574+
var result = ""
575+
var i = sql.startIndex
576+
while i < sql.endIndex {
577+
guard sql[i] == "@" else { result.append(sql[i]); i = sql.index(after: i); continue }
578+
let afterAt = sql.index(after: i)
579+
guard afterAt < sql.endIndex, sql[afterAt].isLetter || sql[afterAt] == "_" else {
580+
result.append(sql[i]); i = sql.index(after: i); continue
581+
}
582+
var end = afterAt
583+
while end < sql.endIndex && (sql[end].isLetter || sql[end].isNumber || sql[end] == "_") {
584+
end = sql.index(after: end)
585+
}
586+
let name = String(sql[afterAt..<end])
587+
if let value = params[name] {
588+
result += value.pgLiteral
589+
} else {
590+
result += "@"; result += name
591+
}
592+
i = end
593+
}
594+
return result
595+
}
596+
520597
// MARK: - Result collection
521598

522599
private func collectResults() async throws -> [SQLRow] {

0 commit comments

Comments
 (0)