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
4 changes: 3 additions & 1 deletion .github/workflows/ios-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ jobs:
uses: actions/cache@v4
with:
path: Libs
key: ${{ runner.os }}-libs-${{ hashFiles('Libs/checksums.sha256') }}
# Include the FreeTDS stub header in the cache key so iOS xcframework refreshes
# whenever the C bridge surface (e.g. new symbol declarations) changes.
key: ${{ runner.os }}-libs-${{ hashFiles('Libs/checksums.sha256', 'Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h') }}

- name: Download static libraries
env:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- 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.
- 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).

## [0.41.0] - 2026-05-13

### Added
Expand Down
13 changes: 12 additions & 1 deletion Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ let package = Package(
.library(name: "TableProDatabase", targets: ["TableProDatabase"]),
.library(name: "TableProQuery", targets: ["TableProQuery"]),
.library(name: "TableProSync", targets: ["TableProSync"]),
.library(name: "TableProAnalytics", targets: ["TableProAnalytics"])
.library(name: "TableProAnalytics", targets: ["TableProAnalytics"]),
.library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"])
],
targets: [
.target(
Expand Down Expand Up @@ -47,6 +48,11 @@ let package = Package(
dependencies: [],
path: "Sources/TableProAnalytics"
),
.target(
name: "TableProMSSQLCore",
dependencies: [],
path: "Sources/TableProMSSQLCore"
),
.testTarget(
name: "TableProModelsTests",
dependencies: ["TableProModels", "TableProPluginKit"],
Expand All @@ -66,6 +72,11 @@ let package = Package(
name: "TableProAnalyticsTests",
dependencies: ["TableProAnalytics"],
path: "Tests/TableProAnalyticsTests"
),
.testTarget(
name: "TableProMSSQLCoreTests",
dependencies: ["TableProMSSQLCore"],
path: "Tests/TableProMSSQLCoreTests"
)
]
)
104 changes: 104 additions & 0 deletions Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLColumnType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation

public enum MSSQLColumnType: Sendable, Equatable {
case char
case varchar
case text
case nchar
case nvarchar
case ntext
case tinyInt
case smallInt
case int
case bigInt
case float
case real
case decimal
case money
case smallMoney
case bit
case binary
case varbinary
case image
case dateTime
case smallDateTime
case dateTimeN
case date
case time
case dateTime2
case dateTimeOffset
case uniqueIdentifier
case xml
case sqlVariant
case unknown(Int32)

public var canonicalName: String {
switch self {
case .char: return "char"
case .varchar: return "varchar"
case .text: return "text"
case .nchar: return "nchar"
case .nvarchar: return "nvarchar"
case .ntext: return "ntext"
case .tinyInt: return "tinyint"
case .smallInt: return "smallint"
case .int: return "int"
case .bigInt: return "bigint"
case .float: return "float"
case .real: return "real"
case .decimal: return "decimal"
case .money: return "money"
case .smallMoney: return "smallmoney"
case .bit: return "bit"
case .binary: return "binary"
case .varbinary: return "varbinary"
case .image: return "image"
case .dateTime, .dateTimeN: return "datetime"
case .smallDateTime: return "smalldatetime"
case .date: return "date"
case .time: return "time"
case .dateTime2: return "datetime2"
case .dateTimeOffset: return "datetimeoffset"
case .uniqueIdentifier: return "uniqueidentifier"
case .xml: return "xml"
case .sqlVariant: return "sql_variant"
case .unknown: return "unknown"
}
}

public var isDateOrTime: Bool {
switch self {
case .dateTime, .smallDateTime, .dateTimeN, .date, .time, .dateTime2, .dateTimeOffset:
return true
default:
return false
}
}

public var isBinary: Bool {
switch self {
case .binary, .varbinary, .image:
return true
default:
return false
}
}

public var isUnicodeString: Bool {
switch self {
case .nchar, .nvarchar, .ntext:
return true
default:
return false
}
}

public var isNarrowString: Bool {
switch self {
case .char, .varchar, .text:
return true
default:
return false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

public struct MSSQLConnectionOptions: Sendable, Equatable {
public var host: String
public var port: Int
public var user: String
public var password: String
public var database: String
public var schema: String
public var encryptionFlag: String
public var applicationName: String
public var loginTimeoutSeconds: Int

public static let defaultPort = 1433
public static let defaultSchema = "dbo"
public static let defaultApplicationName = "TablePro"
public static let defaultEncryptionFlag = "off"
public static let defaultLoginTimeoutSeconds = 30

public init(
host: String,
port: Int = MSSQLConnectionOptions.defaultPort,
user: String,
password: String,
database: String,
schema: String = MSSQLConnectionOptions.defaultSchema,
encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag,
applicationName: String = MSSQLConnectionOptions.defaultApplicationName,
loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds
) {
self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
self.schema = schema
self.encryptionFlag = encryptionFlag
self.applicationName = applicationName
self.loginTimeoutSeconds = loginTimeoutSeconds
}
}

public extension MSSQLConnectionOptions {
enum AdditionalFieldKey {
public static let schema = "mssqlSchema"
}

static func schema(from additionalFields: [String: String]) -> String {
let raw = additionalFields[AdditionalFieldKey.schema] ?? ""
return raw.isEmpty ? defaultSchema : raw
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

public enum MSSQLCoreError: LocalizedError, Sendable {
case connectionFailed(String)
case notConnected
case queryFailed(String)
case cancelled
case tlsHandshakeFailed(String)

public var errorDescription: String? {
switch self {
case .connectionFailed(let detail):
return String(format: String(localized: "Connection failed: %@"), detail)
case .notConnected:
return String(localized: "Not connected to SQL Server")
case .queryFailed(let detail):
return String(format: String(localized: "Query failed: %@"), detail)
case .cancelled:
return String(localized: "Query was cancelled")
case .tlsHandshakeFailed(let detail):
return String(format: String(localized: "TLS handshake failed: %@"), detail)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Foundation

public enum MSSQLDatetimeFormatter {
public static func reformat(_ raw: String, type: MSSQLColumnType) -> String? {
guard type.isDateOrTime else { return nil }
return parse(raw)
}

public static func parse(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if isAlreadyISO(trimmed) {
return trimmed
}
return parseLegacyAMPM(trimmed)
}

public static func isAlreadyISO(_ s: String) -> Bool {
let chars = Array(s)
guard chars.count >= 10 else { return false }
return chars[0].isASCIIDigit && chars[1].isASCIIDigit
&& chars[2].isASCIIDigit && chars[3].isASCIIDigit
&& chars[4] == "-"
&& chars[5].isASCIIDigit && chars[6].isASCIIDigit
&& chars[7] == "-"
&& chars[8].isASCIIDigit && chars[9].isASCIIDigit
}

private static func parseLegacyAMPM(_ raw: String) -> String? {
let scanner = Scanner(string: raw)
scanner.charactersToBeSkipped = nil
_ = scanner.scanCharacters(from: .whitespaces)

guard let monthToken = scanner.scanCharacters(from: .letters),
monthToken.count >= 3,
let month = monthNamesByPrefix[String(monthToken.prefix(3))]
else { return nil }

_ = scanner.scanCharacters(from: .whitespaces)
guard let day = scanner.scanInt(), (1...31).contains(day) else { return nil }
_ = scanner.scanCharacters(from: .whitespaces)
guard let year = scanner.scanInt(), (1...9999).contains(year) else { return nil }
_ = scanner.scanCharacters(from: .whitespaces)
guard var hour = scanner.scanInt() else { return nil }

var minute = 0
var second = 0
var fractional = ""

if scanner.scanString(":") != nil {
guard let m = scanner.scanInt(), (0...59).contains(m) else { return nil }
minute = m
}
if scanner.scanString(":") != nil {
guard let s = scanner.scanInt(), (0...59).contains(s) else { return nil }
second = s
}
if scanner.scanString(":") != nil || scanner.scanString(".") != nil {
fractional = scanner.scanCharacters(from: .decimalDigits) ?? ""
}

_ = scanner.scanCharacters(from: .whitespaces)
let ampm = scanner.scanCharacters(from: .letters)?.uppercased()

if let ampm {
guard ampm == "AM" || ampm == "PM" else { return nil }
guard (1...12).contains(hour) else { return nil }
if ampm == "PM", hour < 12 {
hour += 12
} else if ampm == "AM", hour == 12 {
hour = 0
}
} else {
guard (0...23).contains(hour) else { return nil }
}

var iso = String(format: "%04d-%02d-%02d %02d:%02d:%02d", year, month, day, hour, minute, second)
if !fractional.isEmpty {
iso += "." + fractional
}
return iso
}

private static let monthNamesByPrefix: [String: Int] = [
"Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
"Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12
]
}

private extension Character {
var isASCIIDigit: Bool { isASCII && isNumber }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

public struct MSSQLColumnDescriptor: Sendable, Equatable {
public let name: String
public let type: MSSQLColumnType

public init(name: String, type: MSSQLColumnType) {
self.name = name
self.type = type
}
}

public enum MSSQLRawCell: Sendable, Equatable {
case null
case string(String)
case bytes(Data)

public var stringValue: String? {
switch self {
case .null: return nil
case .string(let s): return s
case .bytes(let d): return String(data: d, encoding: .utf8)
}
}
}

public struct MSSQLRawResult: Sendable {
public let columns: [MSSQLColumnDescriptor]
public let rows: [[MSSQLRawCell]]
public let affectedRows: Int
public let isTruncated: Bool

public init(columns: [MSSQLColumnDescriptor], rows: [[MSSQLRawCell]], affectedRows: Int, isTruncated: Bool) {
self.columns = columns
self.rows = rows
self.affectedRows = affectedRows
self.isTruncated = isTruncated
}
}

public enum MSSQLStreamElement: Sendable {
case header(columns: [MSSQLColumnDescriptor])
case rows([[MSSQLRawCell]])
case affectedRows(Int)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

public enum MSSQLRowLimits {
public static let emergencyMax = 5_000_000
public static let streamBatchSize = 5_000
}
Loading
Loading