Skip to content

Commit 591767a

Browse files
authored
feat(ios): SQL Server (MSSQL) driver via FreeTDS (#1252)
* feat(ios): SQL Server (MSSQL) driver via FreeTDS with SSLConfiguration * refactor(ios-mssql): deterministic pagination + native cancellation + more tests * refactor(mssql)!: share FreeTDSConnection desktop+iOS, MSSQL SSL picker, more tests * fix(mssql): SSL UX, login timeout knob, restore daily-repo-status workflow * ci(ios): bust cache key on FreeTDS stub change; nonisolated FreeTDSConnection
1 parent 245bb9f commit 591767a

31 files changed

Lines changed: 2316 additions & 723 deletions

.github/workflows/ios-tests.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ jobs:
5757
uses: actions/cache@v4
5858
with:
5959
path: Libs
60-
key: ${{ runner.os }}-libs-${{ hashFiles('Libs/checksums.sha256') }}
60+
# Include the FreeTDS stub header in the cache key so iOS xcframework refreshes
61+
# whenever the C bridge surface (e.g. new symbol declarations) changes.
62+
key: ${{ runner.os }}-libs-${{ hashFiles('Libs/checksums.sha256', 'Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h') }}
6163

6264
- name: Download static libraries
6365
env:

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- iOS: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions.
13+
- iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch).
14+
1015
## [0.41.0] - 2026-05-13
1116

1217
### Added

Packages/TableProCore/Package.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ let package = Package(
1414
.library(name: "TableProDatabase", targets: ["TableProDatabase"]),
1515
.library(name: "TableProQuery", targets: ["TableProQuery"]),
1616
.library(name: "TableProSync", targets: ["TableProSync"]),
17-
.library(name: "TableProAnalytics", targets: ["TableProAnalytics"])
17+
.library(name: "TableProAnalytics", targets: ["TableProAnalytics"]),
18+
.library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"])
1819
],
1920
targets: [
2021
.target(
@@ -47,6 +48,11 @@ let package = Package(
4748
dependencies: [],
4849
path: "Sources/TableProAnalytics"
4950
),
51+
.target(
52+
name: "TableProMSSQLCore",
53+
dependencies: [],
54+
path: "Sources/TableProMSSQLCore"
55+
),
5056
.testTarget(
5157
name: "TableProModelsTests",
5258
dependencies: ["TableProModels", "TableProPluginKit"],
@@ -66,6 +72,11 @@ let package = Package(
6672
name: "TableProAnalyticsTests",
6773
dependencies: ["TableProAnalytics"],
6874
path: "Tests/TableProAnalyticsTests"
75+
),
76+
.testTarget(
77+
name: "TableProMSSQLCoreTests",
78+
dependencies: ["TableProMSSQLCore"],
79+
path: "Tests/TableProMSSQLCoreTests"
6980
)
7081
]
7182
)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import Foundation
2+
3+
public enum MSSQLColumnType: Sendable, Equatable {
4+
case char
5+
case varchar
6+
case text
7+
case nchar
8+
case nvarchar
9+
case ntext
10+
case tinyInt
11+
case smallInt
12+
case int
13+
case bigInt
14+
case float
15+
case real
16+
case decimal
17+
case money
18+
case smallMoney
19+
case bit
20+
case binary
21+
case varbinary
22+
case image
23+
case dateTime
24+
case smallDateTime
25+
case dateTimeN
26+
case date
27+
case time
28+
case dateTime2
29+
case dateTimeOffset
30+
case uniqueIdentifier
31+
case xml
32+
case sqlVariant
33+
case unknown(Int32)
34+
35+
public var canonicalName: String {
36+
switch self {
37+
case .char: return "char"
38+
case .varchar: return "varchar"
39+
case .text: return "text"
40+
case .nchar: return "nchar"
41+
case .nvarchar: return "nvarchar"
42+
case .ntext: return "ntext"
43+
case .tinyInt: return "tinyint"
44+
case .smallInt: return "smallint"
45+
case .int: return "int"
46+
case .bigInt: return "bigint"
47+
case .float: return "float"
48+
case .real: return "real"
49+
case .decimal: return "decimal"
50+
case .money: return "money"
51+
case .smallMoney: return "smallmoney"
52+
case .bit: return "bit"
53+
case .binary: return "binary"
54+
case .varbinary: return "varbinary"
55+
case .image: return "image"
56+
case .dateTime, .dateTimeN: return "datetime"
57+
case .smallDateTime: return "smalldatetime"
58+
case .date: return "date"
59+
case .time: return "time"
60+
case .dateTime2: return "datetime2"
61+
case .dateTimeOffset: return "datetimeoffset"
62+
case .uniqueIdentifier: return "uniqueidentifier"
63+
case .xml: return "xml"
64+
case .sqlVariant: return "sql_variant"
65+
case .unknown: return "unknown"
66+
}
67+
}
68+
69+
public var isDateOrTime: Bool {
70+
switch self {
71+
case .dateTime, .smallDateTime, .dateTimeN, .date, .time, .dateTime2, .dateTimeOffset:
72+
return true
73+
default:
74+
return false
75+
}
76+
}
77+
78+
public var isBinary: Bool {
79+
switch self {
80+
case .binary, .varbinary, .image:
81+
return true
82+
default:
83+
return false
84+
}
85+
}
86+
87+
public var isUnicodeString: Bool {
88+
switch self {
89+
case .nchar, .nvarchar, .ntext:
90+
return true
91+
default:
92+
return false
93+
}
94+
}
95+
96+
public var isNarrowString: Bool {
97+
switch self {
98+
case .char, .varchar, .text:
99+
return true
100+
default:
101+
return false
102+
}
103+
}
104+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Foundation
2+
3+
public struct MSSQLConnectionOptions: Sendable, Equatable {
4+
public var host: String
5+
public var port: Int
6+
public var user: String
7+
public var password: String
8+
public var database: String
9+
public var schema: String
10+
public var encryptionFlag: String
11+
public var applicationName: String
12+
public var loginTimeoutSeconds: Int
13+
14+
public static let defaultPort = 1433
15+
public static let defaultSchema = "dbo"
16+
public static let defaultApplicationName = "TablePro"
17+
public static let defaultEncryptionFlag = "off"
18+
public static let defaultLoginTimeoutSeconds = 30
19+
20+
public init(
21+
host: String,
22+
port: Int = MSSQLConnectionOptions.defaultPort,
23+
user: String,
24+
password: String,
25+
database: String,
26+
schema: String = MSSQLConnectionOptions.defaultSchema,
27+
encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag,
28+
applicationName: String = MSSQLConnectionOptions.defaultApplicationName,
29+
loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds
30+
) {
31+
self.host = host
32+
self.port = port
33+
self.user = user
34+
self.password = password
35+
self.database = database
36+
self.schema = schema
37+
self.encryptionFlag = encryptionFlag
38+
self.applicationName = applicationName
39+
self.loginTimeoutSeconds = loginTimeoutSeconds
40+
}
41+
}
42+
43+
public extension MSSQLConnectionOptions {
44+
enum AdditionalFieldKey {
45+
public static let schema = "mssqlSchema"
46+
}
47+
48+
static func schema(from additionalFields: [String: String]) -> String {
49+
let raw = additionalFields[AdditionalFieldKey.schema] ?? ""
50+
return raw.isEmpty ? defaultSchema : raw
51+
}
52+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Foundation
2+
3+
public enum MSSQLCoreError: LocalizedError, Sendable {
4+
case connectionFailed(String)
5+
case notConnected
6+
case queryFailed(String)
7+
case cancelled
8+
case tlsHandshakeFailed(String)
9+
10+
public var errorDescription: String? {
11+
switch self {
12+
case .connectionFailed(let detail):
13+
return String(format: String(localized: "Connection failed: %@"), detail)
14+
case .notConnected:
15+
return String(localized: "Not connected to SQL Server")
16+
case .queryFailed(let detail):
17+
return String(format: String(localized: "Query failed: %@"), detail)
18+
case .cancelled:
19+
return String(localized: "Query was cancelled")
20+
case .tlsHandshakeFailed(let detail):
21+
return String(format: String(localized: "TLS handshake failed: %@"), detail)
22+
}
23+
}
24+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import Foundation
2+
3+
public enum MSSQLDatetimeFormatter {
4+
public static func reformat(_ raw: String, type: MSSQLColumnType) -> String? {
5+
guard type.isDateOrTime else { return nil }
6+
return parse(raw)
7+
}
8+
9+
public static func parse(_ raw: String) -> String? {
10+
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
11+
guard !trimmed.isEmpty else { return nil }
12+
if isAlreadyISO(trimmed) {
13+
return trimmed
14+
}
15+
return parseLegacyAMPM(trimmed)
16+
}
17+
18+
public static func isAlreadyISO(_ s: String) -> Bool {
19+
let chars = Array(s)
20+
guard chars.count >= 10 else { return false }
21+
return chars[0].isASCIIDigit && chars[1].isASCIIDigit
22+
&& chars[2].isASCIIDigit && chars[3].isASCIIDigit
23+
&& chars[4] == "-"
24+
&& chars[5].isASCIIDigit && chars[6].isASCIIDigit
25+
&& chars[7] == "-"
26+
&& chars[8].isASCIIDigit && chars[9].isASCIIDigit
27+
}
28+
29+
private static func parseLegacyAMPM(_ raw: String) -> String? {
30+
let scanner = Scanner(string: raw)
31+
scanner.charactersToBeSkipped = nil
32+
_ = scanner.scanCharacters(from: .whitespaces)
33+
34+
guard let monthToken = scanner.scanCharacters(from: .letters),
35+
monthToken.count >= 3,
36+
let month = monthNamesByPrefix[String(monthToken.prefix(3))]
37+
else { return nil }
38+
39+
_ = scanner.scanCharacters(from: .whitespaces)
40+
guard let day = scanner.scanInt(), (1...31).contains(day) else { return nil }
41+
_ = scanner.scanCharacters(from: .whitespaces)
42+
guard let year = scanner.scanInt(), (1...9999).contains(year) else { return nil }
43+
_ = scanner.scanCharacters(from: .whitespaces)
44+
guard var hour = scanner.scanInt() else { return nil }
45+
46+
var minute = 0
47+
var second = 0
48+
var fractional = ""
49+
50+
if scanner.scanString(":") != nil {
51+
guard let m = scanner.scanInt(), (0...59).contains(m) else { return nil }
52+
minute = m
53+
}
54+
if scanner.scanString(":") != nil {
55+
guard let s = scanner.scanInt(), (0...59).contains(s) else { return nil }
56+
second = s
57+
}
58+
if scanner.scanString(":") != nil || scanner.scanString(".") != nil {
59+
fractional = scanner.scanCharacters(from: .decimalDigits) ?? ""
60+
}
61+
62+
_ = scanner.scanCharacters(from: .whitespaces)
63+
let ampm = scanner.scanCharacters(from: .letters)?.uppercased()
64+
65+
if let ampm {
66+
guard ampm == "AM" || ampm == "PM" else { return nil }
67+
guard (1...12).contains(hour) else { return nil }
68+
if ampm == "PM", hour < 12 {
69+
hour += 12
70+
} else if ampm == "AM", hour == 12 {
71+
hour = 0
72+
}
73+
} else {
74+
guard (0...23).contains(hour) else { return nil }
75+
}
76+
77+
var iso = String(format: "%04d-%02d-%02d %02d:%02d:%02d", year, month, day, hour, minute, second)
78+
if !fractional.isEmpty {
79+
iso += "." + fractional
80+
}
81+
return iso
82+
}
83+
84+
private static let monthNamesByPrefix: [String: Int] = [
85+
"Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
86+
"Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12
87+
]
88+
}
89+
90+
private extension Character {
91+
var isASCIIDigit: Bool { isASCII && isNumber }
92+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
3+
public struct MSSQLColumnDescriptor: Sendable, Equatable {
4+
public let name: String
5+
public let type: MSSQLColumnType
6+
7+
public init(name: String, type: MSSQLColumnType) {
8+
self.name = name
9+
self.type = type
10+
}
11+
}
12+
13+
public enum MSSQLRawCell: Sendable, Equatable {
14+
case null
15+
case string(String)
16+
case bytes(Data)
17+
18+
public var stringValue: String? {
19+
switch self {
20+
case .null: return nil
21+
case .string(let s): return s
22+
case .bytes(let d): return String(data: d, encoding: .utf8)
23+
}
24+
}
25+
}
26+
27+
public struct MSSQLRawResult: Sendable {
28+
public let columns: [MSSQLColumnDescriptor]
29+
public let rows: [[MSSQLRawCell]]
30+
public let affectedRows: Int
31+
public let isTruncated: Bool
32+
33+
public init(columns: [MSSQLColumnDescriptor], rows: [[MSSQLRawCell]], affectedRows: Int, isTruncated: Bool) {
34+
self.columns = columns
35+
self.rows = rows
36+
self.affectedRows = affectedRows
37+
self.isTruncated = isTruncated
38+
}
39+
}
40+
41+
public enum MSSQLStreamElement: Sendable {
42+
case header(columns: [MSSQLColumnDescriptor])
43+
case rows([[MSSQLRawCell]])
44+
case affectedRows(Int)
45+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Foundation
2+
3+
public enum MSSQLRowLimits {
4+
public static let emergencyMax = 5_000_000
5+
public static let streamBatchSize = 5_000
6+
}

0 commit comments

Comments
 (0)