Skip to content

Modern Swift client for Microsoft SQL Server (macOS, iOS, Linux). A lightweight async/await wrapper around FreeTDS for robust, cross-platform database connectivity.

License

Notifications You must be signed in to change notification settings

vkuttyp/SQLClient-Swift

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

61 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SQLClient-Swift

A modern, native Microsoft SQL Server client for iOS, macOS, and Linux — written in Swift.

Built on top of the open-source FreeTDS library, SQLClient-Swift provides a clean async/await API, automatic Decodable row mapping, full TLS/encryption support for Azure SQL and SQL Server 2022, and thread safety via Swift's actor model.

This is a Swift rewrite and modernisation of martinrybak/SQLClient, bringing it up to date with FreeTDS 1.5 and modern Swift Concurrency.


Features

  • async/await API — no completion handlers, no callbacks
  • Swift actor — connection state is safe across concurrent callers by design
  • Decodable row mapping — map query results directly to your Swift structs
  • Typed SQLRow — access columns as String, Int, Date, UUID, Decimal, and more
  • SQLDataTable & SQLDataSet — typed, named tables with JSON serialisation and Markdown rendering
  • Full TLS supportoff, request, require, and strict (TDS 8.0 / Azure SQL)
  • FreeTDS 1.5 — NTLMv2, read-only AG routing, Kerberos auth, IPv6, cluster failover
  • Affected-row countsrowsAffected from INSERT / UPDATE / DELETE
  • Parameterised queries — built-in SQL injection protection via ? placeholders
  • All SQL Server date typesdate, time, datetime2, datetimeoffset as native Date
  • Swift Package Manager — single dependency, no Ruby tooling required

Requirements

Component Minimum version
iOS 16.0
macOS 13.0
tvOS 16.0
Xcode 15.0
Swift 5.9
FreeTDS 1.0 (1.5 recommended)

Installation

Swift Package Manager

Add the dependency to your Package.swift:

dependencies: [
    .package(url: "https://github.com/vkuttyp/SQLClient-Swift.git", from: "1.0.0")
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(name: "SQLClientSwift", package: "SQLClient-Swift")
        ]
    )
]

Or in Xcode: File → Add Package Dependencies… and enter the repository URL.

Install FreeTDS

SQLClient-Swift wraps FreeTDS — you need the native library present at build time.

macOS (Homebrew):

brew install freetds

Linux (apt):

sudo apt install freetds-dev

iOS / custom build: Use a pre-compiled libsybdb.a (e.g. from FreeTDS-iOS) and link it manually in your Xcode target under Build Phases → Link Binary With Libraries, along with libiconv.tbd.


Quick Start

import SQLClientSwift

let client = SQLClient.shared

// Connect
try await client.connect(
    server:   "myserver.database.windows.net",
    username: "myuser",
    password: "mypassword",
    database: "MyDatabase"
)

// Query
let rows = try await client.query("SELECT id, name FROM Users")
for row in rows {
    print(row.int("id") ?? 0, row.string("name") ?? "")
}

// Disconnect
await client.disconnect()

Usage

Connecting

The simplest form uses individual parameters:

try await client.connect(
    server:   "hostname\\instance",   // or "hostname:1433"
    username: "sa",
    password: "secret",
    database: "MyDB"                  // optional
)

For advanced options, use SQLClientConnectionOptions:

var options = SQLClientConnectionOptions(
    server:   "myserver.database.windows.net",
    username: "myuser",
    password: "mypassword",
    database: "MyDatabase"
)
options.port          = 1433
options.encryption    = .strict    // required for Azure SQL / SQL Server 2022
options.loginTimeout  = 10        // seconds
options.queryTimeout  = 30        // seconds
options.readOnly      = true      // connect to an Availability Group read replica

try await client.connect(options: options)

Querying — SQLRow

query() returns [SQLRow] from the first result set. Each SQLRow provides ordered, typed column access:

let rows = try await client.query("SELECT * FROM Products")

for row in rows {
    let id:     Int?     = row.int("ProductID")
    let name:   String?  = row.string("Name")
    let price:  Decimal? = row.decimal("Price")
    let added:  Date?    = row.date("DateAdded")
    let sku:    UUID?    = row.uuid("SKU")
    let thumb:  Data?    = row.data("Thumbnail")
    let active: Bool?    = row.bool("IsActive")

    if row.isNull("DiscontinuedDate") {
        print("\(name ?? "") is still available")
    }
}

You can also access columns by zero-based index:

let firstColumn = row[0]

Querying — Decodable Mapping

Map rows directly to your own Decodable structs. Column name matching is case-insensitive and handles snake_casecamelCase automatically:

struct Product: Decodable {
    let productID: Int
    let name: String
    let price: Decimal
    let dateAdded: Date
}

// "product_id", "ProductID", and "productId" all match the `productID` property
let products: [Product] = try await client.query(
    "SELECT product_id, name, price, date_added FROM Products"
)

Executing — SQLClientResult

execute() returns a SQLClientResult containing all result sets and the affected-row count. Use this when running multi-statement batches or when you need rowsAffected:

let result = try await client.execute("""
    SELECT * FROM Orders WHERE Status = 'Open';
    SELECT COUNT(*) AS Total FROM Orders;
""")

let openOrders = result.tables[0]          // first result set
let countRow   = result.tables[1].first    // second result set
print("Total orders:", countRow?.int("Total") ?? 0)

DML — INSERT, UPDATE, DELETE

Use run() for data-modification statements. It returns the number of affected rows:

let affected = try await client.run(
    "UPDATE Users SET LastLogin = GETDATE() WHERE UserID = 42"
)
print("\(affected) row(s) updated")

Parameterised Queries

Use ? placeholders to pass values safely. Strings are automatically escaped to prevent SQL injection:

// SELECT with parameters
let rows = try await client.execute(
    "SELECT * FROM Users WHERE Name = ? AND Active = ?",
    parameters: ["O'Brien", true]
)

// INSERT with parameters
try await client.run(
    "INSERT INTO Log (UserID, Message, CreatedAt) VALUES (?, ?, ?)",
    parameters: [42, "Logged in", Date()]
)

// NULL parameter
try await client.run(
    "UPDATE Users SET ManagerID = ? WHERE UserID = ?",
    parameters: [nil, 7]
)

Note: This uses string-level escaping (single-quote doubling). For maximum security with untrusted user input, prefer stored procedures.

SQLDataTable & SQLDataSet

SQLDataTable is a typed, named result table — the Swift equivalent of .NET's DataTable. Each cell is a strongly-typed SQLCellValue enum, the table is Codable for JSON serialisation, and it can render itself as a Markdown table.

SQLDataSet is a collection of SQLDataTable instances, used when a query or stored procedure returns multiple result sets.

Fetching a single table

let table = try await client.dataTable("SELECT * FROM Users")

print(table.rowCount)    // number of rows
print(table.columnCount) // number of columns

Cell access

// By row index and column name (case-insensitive)
let cell: SQLCellValue = table[0, "Name"]

// By row and column index
let cell: SQLCellValue = table[0, 0]

// As a typed value
switch table[0, "Age"] {
case .int32(let age): print("Age:", age)
case .null:           print("Age unknown")
default:              break
}

// As Any? for interop with existing code
let raw: Any? = table[0, "Name"].anyValue

// Whole row as a dictionary
let dict: [String: SQLCellValue] = table.row(at: 0)

// All values in a column
let names: [SQLCellValue] = table.column(named: "Name")

Markdown rendering

print(table.toMarkdown())

Output example:

| ID | Name  | Email             |
|---|---|---|
| 1  | Alice | alice@example.com |
| 2  | Bob   | bob@example.com   |

Decoding rows into a Decodable struct

struct User: Decodable {
    let id: Int
    let name: String
    let email: String
}

let users: [User] = try table.decode()

JSON serialisation

SQLDataTable and SQLDataSet are fully Codable:

let json = try JSONEncoder().encode(table)
let restored = try JSONDecoder().decode(SQLDataTable.self, from: json)

Converting an existing SQLClientResult

let result = try await client.execute("SELECT * FROM Orders")

// First result set as SQLDataTable
let table = result.asDataTable(name: "Orders")

// All result sets as SQLDataSet
let ds = result.asSQLDataSet()

Multi-table — SQLDataSet

Use dataSet() when a stored procedure or batch returns more than one result set:

let ds = try await client.dataSet("EXEC sp_GetDashboard")

// Access by index
let summary = ds[0]

// Access by name (case-insensitive, uses the table name assigned by the procedure)
let details = ds["Details"]

print(ds.count) // number of tables

Backward compatibility

SQLDataTable can be converted back to [SQLRow] if you need to pass it to existing code:

let sqlRows: [SQLRow] = table.toSQLRows()

Error Handling

All errors are thrown as SQLClientError, which conforms to LocalizedError:

do {
    try await client.connect(server: "badhost", username: "sa", password: "wrong")
} catch SQLClientError.connectionFailed(let server) {
    print("Could not reach \(server)")
} catch SQLClientError.alreadyConnected {
    print("Already connected — call disconnect() first")
} catch {
    print("Error:", error.localizedDescription)
}
Error case When thrown
.alreadyConnected connect() called while already connected
.notConnected execute() or query() called before connecting
.loginAllocationFailed FreeTDS internal allocation failure
.connectionFailed(server:) TCP connection or login rejected by the server
.databaseSelectionFailed(_) USE <database> command failed
.executionFailed dbsqlexec() returned an error
.noCommandText An empty SQL string was passed

Server Messages

Informational messages from SQL Server (PRINT, low-severity RAISERROR) are delivered via NotificationCenter rather than thrown, since they are non-fatal:

NotificationCenter.default.addObserver(
    forName: .SQLClientMessage,
    object: nil,
    queue: .main
) { notification in
    let code     = notification.userInfo?[SQLClientMessageKey.code]     as? Int    ?? 0
    let message  = notification.userInfo?[SQLClientMessageKey.message]  as? String ?? ""
    let severity = notification.userInfo?[SQLClientMessageKey.severity] as? Int    ?? 0
    print("Server message [\(severity)] #\(code): \(message)")
}

Encryption & Azure SQL

Mode Description Use when
.off No TLS On-premise, fully trusted network
.request Opportunistic TLS (default) General on-premise use
.require Always encrypt, skip cert validation Self-signed certificates
.strict TDS 8.0 — always encrypt, validate cert Azure SQL, SQL Server 2022

For Azure SQL Database or any server with forced encryption enabled:

var options = SQLClientConnectionOptions(
    server:   "yourserver.database.windows.net",
    username: "myuser",
    password: "mypassword"
)
options.encryption = .strict
try await client.connect(options: options)

Type Mapping

SQL Server type Swift type SQLCellValue case
tinyint NSNumber (UInt8) .int16
smallint NSNumber (Int16) .int16
int NSNumber (Int32) .int32
bigint NSNumber (Int64) .int64
bit NSNumber (Bool) .bool
real NSNumber (Float) .float
float NSNumber (Double) .double
decimal, numeric NSDecimalNumber .decimal
money, smallmoney NSDecimalNumber (4 dp) .decimal
char, varchar, nchar, nvarchar String .string
text, ntext, xml String .string
binary, varbinary, image Data .bytes
timestamp Data .bytes
datetime, smalldatetime Date .date
date, time, datetime2, datetimeoffset Date .date
uniqueidentifier UUID .uuid
null NSNull .null
sql_variant, cursor, table ⚠️ Not supported

Date types note: date, time, datetime2, and datetimeoffset are returned as Date when using TDS 7.3 or higher. FreeTDS 1.x defaults to auto protocol negotiation, which will select 7.3+ automatically for modern SQL Server versions. If you see strings instead of dates on an older server, set the TDSVER environment variable in your Xcode scheme to 7.3 or auto.


Configuration

Max Text Size

Controls the maximum bytes returned for TEXT, NTEXT, and VARCHAR(MAX) columns. Default is 4096 bytes.

// In your setup code, before connecting:
SQLClient.shared.maxTextSize = 65536

TDS Protocol Version

Set the TDSVER environment variable in your Xcode scheme (Edit Scheme → Run → Arguments → Environment Variables):

Value Protocol Compatible with
auto Autodetect (recommended) All SQL Server versions
7.4 TDS 7.4 SQL Server 2012+
7.3 TDS 7.3 SQL Server 2008
7.2 TDS 7.2 SQL Server 2005
7.1 TDS 7.1 SQL Server 2000

Known Limitations

  • Stored procedure OUTPUT parameters are not yet supported. Stored procedures that return result sets via SELECT work normally.
  • Connection pooling is not built in. For high-concurrency server-side apps, create multiple SQLClient instances manually.
  • Single-space strings: FreeTDS may return "" instead of " " in some server configurations (upstream FreeTDS bug).
  • sql_variant, cursor, and table SQL Server types are not supported.

Credits


License

SQLClient-Swift is released under the MIT License. See LICENSE for details.

FreeTDS is licensed under the GNU LGPL. See the FreeTDS license for details.

About

Modern Swift client for Microsoft SQL Server (macOS, iOS, Linux). A lightweight async/await wrapper around FreeTDS for robust, cross-platform database connectivity.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •