diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index e0e900d1c..61d7ee7bd 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 39dbc2f84..5ea14f328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 302781d3b..ac0d7ae13 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -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( @@ -47,6 +48,11 @@ let package = Package( dependencies: [], path: "Sources/TableProAnalytics" ), + .target( + name: "TableProMSSQLCore", + dependencies: [], + path: "Sources/TableProMSSQLCore" + ), .testTarget( name: "TableProModelsTests", dependencies: ["TableProModels", "TableProPluginKit"], @@ -66,6 +72,11 @@ let package = Package( name: "TableProAnalyticsTests", dependencies: ["TableProAnalytics"], path: "Tests/TableProAnalyticsTests" + ), + .testTarget( + name: "TableProMSSQLCoreTests", + dependencies: ["TableProMSSQLCore"], + path: "Tests/TableProMSSQLCoreTests" ) ] ) diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLColumnType.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLColumnType.swift new file mode 100644 index 000000000..1f49010fa --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLColumnType.swift @@ -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 + } + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift new file mode 100644 index 000000000..9af76fb37 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -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 + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift new file mode 100644 index 000000000..8343aa526 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift @@ -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) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLDatetimeFormatter.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLDatetimeFormatter.swift new file mode 100644 index 000000000..fab4d5993 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLDatetimeFormatter.swift @@ -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 } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRawResult.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRawResult.swift new file mode 100644 index 000000000..f85f2edd5 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRawResult.swift @@ -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) +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRowLimits.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRowLimits.swift new file mode 100644 index 000000000..b91195bda --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRowLimits.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum MSSQLRowLimits { + public static let emergencyMax = 5_000_000 + public static let streamBatchSize = 5_000 +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift new file mode 100644 index 000000000..a0a56b020 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift @@ -0,0 +1,263 @@ +import Foundation + +public enum MSSQLSchemaQueries { + public static func escape(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + public static func escapeBracket(_ value: String) -> String { + value.replacingOccurrences(of: "]", with: "]]") + } + + public static func bracketed(schema: String, table: String) -> String { + "[\(escapeBracket(schema))].[\(escapeBracket(table))]" + } + + public static let currentSchema = "SELECT SCHEMA_NAME()" + public static let serverVersion = "SELECT @@VERSION" + public static let beginTransaction = "BEGIN TRANSACTION" + public static let commitTransaction = "COMMIT TRANSACTION" + public static let rollbackTransaction = "ROLLBACK TRANSACTION" + public static let ping = "SELECT 1" + + public static let databases = "SELECT name FROM sys.databases ORDER BY name" + + public static let schemas = """ + SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA + WHERE SCHEMA_NAME NOT IN ( + 'information_schema','sys','db_owner','db_accessadmin', + 'db_securityadmin','db_ddladmin','db_backupoperator', + 'db_datareader','db_datawriter','db_denydatareader', + 'db_denydatawriter','guest' + ) + ORDER BY SCHEMA_NAME + """ + + public static func tables(schema: String) -> String { + let s = escape(schema) + return """ + SELECT t.TABLE_NAME, t.TABLE_TYPE + FROM INFORMATION_SCHEMA.TABLES t + WHERE t.TABLE_SCHEMA = '\(s)' + AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW') + ORDER BY t.TABLE_NAME + """ + } + + public static func columns(schema: String, table: String) -> String { + let s = escape(schema) + let t = escape(table) + return """ + SELECT + c.COLUMN_NAME, + c.DATA_TYPE, + c.CHARACTER_MAXIMUM_LENGTH, + c.NUMERIC_PRECISION, + c.NUMERIC_SCALE, + c.IS_NULLABLE, + c.COLUMN_DEFAULT, + COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') AS IS_IDENTITY, + CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK + FROM INFORMATION_SCHEMA.COLUMNS c + LEFT JOIN ( + SELECT kcu.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA + WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + AND tc.TABLE_SCHEMA = '\(s)' + AND tc.TABLE_NAME = '\(t)' + ) pk ON c.COLUMN_NAME = pk.COLUMN_NAME + WHERE c.TABLE_NAME = '\(t)' + AND c.TABLE_SCHEMA = '\(s)' + ORDER BY c.ORDINAL_POSITION + """ + } + + public static func indexes(schema: String, table: String) -> String { + let object = bracketed(schema: schema, table: table) + return """ + SELECT i.name, i.is_unique, i.is_primary_key, c.name AS column_name + FROM sys.indexes i + JOIN sys.index_columns ic + ON i.object_id = ic.object_id AND i.index_id = ic.index_id + JOIN sys.columns c + ON ic.object_id = c.object_id AND ic.column_id = c.column_id + WHERE i.object_id = OBJECT_ID('\(object)') + AND i.name IS NOT NULL + ORDER BY i.index_id, ic.key_ordinal + """ + } + + public static func foreignKeys(schema: String, table: String) -> String { + let s = escape(schema) + let t = escape(table) + return """ + SELECT + fk.name AS constraint_name, + cp.name AS column_name, + tr.name AS ref_table, + cr.name AS ref_column + FROM sys.foreign_keys fk + JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id + JOIN sys.tables tp ON fkc.parent_object_id = tp.object_id + JOIN sys.schemas s ON tp.schema_id = s.schema_id + JOIN sys.columns cp + ON fkc.parent_object_id = cp.object_id AND fkc.parent_column_id = cp.column_id + JOIN sys.tables tr ON fkc.referenced_object_id = tr.object_id + JOIN sys.columns cr + ON fkc.referenced_object_id = cr.object_id AND fkc.referenced_column_id = cr.column_id + WHERE tp.name = '\(t)' AND s.name = '\(s)' + ORDER BY fk.name + """ + } +} + +public struct MSSQLTableRow: Sendable, Equatable { + public let name: String + public let isView: Bool + + public init(name: String, isView: Bool) { + self.name = name + self.isView = isView + } +} + +public struct MSSQLColumnRow: Sendable, Equatable { + public let name: String + public let dataType: String + public let characterMaxLength: Int? + public let numericPrecision: Int? + public let numericScale: Int? + public let isNullable: Bool + public let defaultValue: String? + public let isIdentity: Bool + public let isPrimaryKey: Bool + + public init( + name: String, + dataType: String, + characterMaxLength: Int?, + numericPrecision: Int?, + numericScale: Int?, + isNullable: Bool, + defaultValue: String?, + isIdentity: Bool, + isPrimaryKey: Bool + ) { + self.name = name + self.dataType = dataType + self.characterMaxLength = characterMaxLength + self.numericPrecision = numericPrecision + self.numericScale = numericScale + self.isNullable = isNullable + self.defaultValue = defaultValue + self.isIdentity = isIdentity + self.isPrimaryKey = isPrimaryKey + } + + public var displayType: String { + let base = dataType.lowercased() + let fixedSize: Set = [ + "int", "bigint", "smallint", "tinyint", "bit", + "money", "smallmoney", "float", "real", + "datetime", "datetime2", "smalldatetime", "date", "time", + "uniqueidentifier", "text", "ntext", "image", "xml", + "timestamp", "rowversion" + ] + if fixedSize.contains(base) { + return base + } + if let len = characterMaxLength { + return len < 0 ? "\(base)(max)" : "\(base)(\(len))" + } + if let p = numericPrecision, let s = numericScale { + return "\(base)(\(p),\(s))" + } + return base + } +} + +public struct MSSQLIndexRow: Sendable, Equatable { + public let name: String + public let isUnique: Bool + public let isPrimary: Bool + public let columnName: String + + public init(name: String, isUnique: Bool, isPrimary: Bool, columnName: String) { + self.name = name + self.isUnique = isUnique + self.isPrimary = isPrimary + self.columnName = columnName + } +} + +public struct MSSQLForeignKeyRow: Sendable, Equatable { + public let constraintName: String + public let columnName: String + public let referencedTable: String + public let referencedColumn: String + + public init(constraintName: String, columnName: String, referencedTable: String, referencedColumn: String) { + self.constraintName = constraintName + self.columnName = columnName + self.referencedTable = referencedTable + self.referencedColumn = referencedColumn + } +} + +public extension MSSQLSchemaQueries { + static func parseTableRow(_ row: [String?]) -> MSSQLTableRow? { + guard let name = row[safe: 0] ?? nil else { return nil } + let typeRaw = (row[safe: 1] ?? nil) ?? "BASE TABLE" + return MSSQLTableRow(name: name, isView: typeRaw == "VIEW") + } + + static func parseColumnRow(_ row: [String?]) -> MSSQLColumnRow? { + guard let name = row[safe: 0] ?? nil else { return nil } + return MSSQLColumnRow( + name: name, + dataType: (row[safe: 1] ?? nil) ?? "nvarchar", + characterMaxLength: (row[safe: 2] ?? nil).flatMap { Int($0) }, + numericPrecision: (row[safe: 3] ?? nil).flatMap { Int($0) }, + numericScale: (row[safe: 4] ?? nil).flatMap { Int($0) }, + isNullable: (row[safe: 5] ?? nil) == "YES", + defaultValue: row[safe: 6] ?? nil, + isIdentity: (row[safe: 7] ?? nil) == "1", + isPrimaryKey: (row[safe: 8] ?? nil) == "1" + ) + } + + static func parseIndexRow(_ row: [String?]) -> MSSQLIndexRow? { + guard let name = row[safe: 0] ?? nil, + let column = row[safe: 3] ?? nil + else { return nil } + return MSSQLIndexRow( + name: name, + isUnique: (row[safe: 1] ?? nil) == "1", + isPrimary: (row[safe: 2] ?? nil) == "1", + columnName: column + ) + } + + static func parseForeignKeyRow(_ row: [String?]) -> MSSQLForeignKeyRow? { + guard let name = row[safe: 0] ?? nil, + let column = row[safe: 1] ?? nil, + let refTable = row[safe: 2] ?? nil, + let refColumn = row[safe: 3] ?? nil + else { return nil } + return MSSQLForeignKeyRow( + constraintName: name, + columnName: column, + referencedTable: refTable, + referencedColumn: refColumn + ) + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift new file mode 100644 index 000000000..ebd7f12da --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import TableProMSSQLCore + +final class MSSQLColumnTypeTests: XCTestCase { + func testIsBinaryCoversBinaryFamily() { + XCTAssertTrue(MSSQLColumnType.binary.isBinary) + XCTAssertTrue(MSSQLColumnType.varbinary.isBinary) + XCTAssertTrue(MSSQLColumnType.image.isBinary) + XCTAssertFalse(MSSQLColumnType.varchar.isBinary) + XCTAssertFalse(MSSQLColumnType.int.isBinary) + } + + func testIsDateOrTimeCoversAllDateTimeVariants() { + let dateTypes: [MSSQLColumnType] = [ + .dateTime, .smallDateTime, .dateTimeN, + .date, .time, .dateTime2, .dateTimeOffset + ] + for type in dateTypes { + XCTAssertTrue(type.isDateOrTime, "\(type.canonicalName) should be date/time") + } + XCTAssertFalse(MSSQLColumnType.int.isDateOrTime) + XCTAssertFalse(MSSQLColumnType.varchar.isDateOrTime) + } + + func testIsUnicodeStringOnlyForNTypes() { + XCTAssertTrue(MSSQLColumnType.nchar.isUnicodeString) + XCTAssertTrue(MSSQLColumnType.nvarchar.isUnicodeString) + XCTAssertTrue(MSSQLColumnType.ntext.isUnicodeString) + XCTAssertFalse(MSSQLColumnType.char.isUnicodeString) + XCTAssertFalse(MSSQLColumnType.varchar.isUnicodeString) + } + + func testIsNarrowStringOnlyForNonUnicodeStrings() { + XCTAssertTrue(MSSQLColumnType.char.isNarrowString) + XCTAssertTrue(MSSQLColumnType.varchar.isNarrowString) + XCTAssertTrue(MSSQLColumnType.text.isNarrowString) + XCTAssertFalse(MSSQLColumnType.nvarchar.isNarrowString) + XCTAssertFalse(MSSQLColumnType.int.isNarrowString) + } + + func testCanonicalNameForDateTimeFamily() { + XCTAssertEqual(MSSQLColumnType.dateTime.canonicalName, "datetime") + XCTAssertEqual(MSSQLColumnType.dateTimeN.canonicalName, "datetime") + XCTAssertEqual(MSSQLColumnType.smallDateTime.canonicalName, "smalldatetime") + XCTAssertEqual(MSSQLColumnType.dateTime2.canonicalName, "datetime2") + XCTAssertEqual(MSSQLColumnType.dateTimeOffset.canonicalName, "datetimeoffset") + } + + func testUnknownTypePreservesToken() { + let unknown = MSSQLColumnType.unknown(99) + XCTAssertEqual(unknown.canonicalName, "unknown") + if case .unknown(let token) = unknown { + XCTAssertEqual(token, 99) + } else { + XCTFail("expected .unknown case") + } + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift new file mode 100644 index 000000000..ea6ffc336 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import TableProMSSQLCore + +final class MSSQLDatetimeFormatterTests: XCTestCase { + func testDatetimeIsReformatted() { + let result = MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00:123AM", type: .dateTime) + XCTAssertEqual(result, "2024-01-15 10:30:00.123") + } + + func testPMHoursAreAdjusted() { + let result = MSSQLDatetimeFormatter.reformat("Mar 5 2024 2:45:30PM", type: .dateTime) + XCTAssertEqual(result, "2024-03-05 14:45:30") + } + + func testNoonHandledCorrectly() { + XCTAssertEqual(MSSQLDatetimeFormatter.parse("Jun 1 2024 12:00:00PM"), "2024-06-01 12:00:00") + } + + func testMidnightHandledCorrectly() { + XCTAssertEqual(MSSQLDatetimeFormatter.parse("Jun 1 2024 12:00:00AM"), "2024-06-01 00:00:00") + } + + func testAlreadyISOPassesThrough() { + let raw = "2024-01-15 10:30:00.123" + XCTAssertEqual(MSSQLDatetimeFormatter.parse(raw), raw) + } + + func testReformatReturnsNilForNonDatetimeType() { + XCTAssertNil(MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00AM", type: .int)) + XCTAssertNil(MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00AM", type: .nvarchar)) + } + + func testEmptyInputReturnsNil() { + XCTAssertNil(MSSQLDatetimeFormatter.parse("")) + XCTAssertNil(MSSQLDatetimeFormatter.parse(" ")) + } + + func testInvalidMonthReturnsNil() { + XCTAssertNil(MSSQLDatetimeFormatter.parse("Xyz 1 2024 10:00AM")) + } + + func testFractionalSecondsPreserved() { + let result = MSSQLDatetimeFormatter.parse("Jan 1 2024 1:00:00:1234567AM") + XCTAssertEqual(result, "2024-01-01 01:00:00.1234567") + } + + func testDate2025Handled() { + XCTAssertEqual(MSSQLDatetimeFormatter.reformat("Dec 31 2025 11:59:59:999PM", type: .dateTime2), + "2025-12-31 23:59:59.999") + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift new file mode 100644 index 000000000..2b488d5d5 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import TableProMSSQLCore + +final class MSSQLSchemaQueriesTests: XCTestCase { + func testEscapeHandlesSingleQuote() { + XCTAssertEqual(MSSQLSchemaQueries.escape("O'Brien"), "O''Brien") + XCTAssertEqual(MSSQLSchemaQueries.escape("plain"), "plain") + } + + func testEscapeBracketHandlesClosingBracket() { + XCTAssertEqual(MSSQLSchemaQueries.escapeBracket("weird]name"), "weird]]name") + } + + func testBracketedComposesIdentifier() { + XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "dbo", table: "Users"), "[dbo].[Users]") + XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "weird]", table: "x"), "[weird]]].[x]") + } + + func testTablesQueryEscapesSchema() { + let sql = MSSQLSchemaQueries.tables(schema: "O'Brien") + XCTAssertTrue(sql.contains("'O''Brien'")) + XCTAssertTrue(sql.contains("INFORMATION_SCHEMA.TABLES")) + XCTAssertTrue(sql.contains("'BASE TABLE'")) + XCTAssertTrue(sql.contains("'VIEW'")) + } + + func testColumnsQueryIncludesIdentityAndPrimaryKey() { + let sql = MSSQLSchemaQueries.columns(schema: "dbo", table: "Users") + XCTAssertTrue(sql.contains("IsIdentity")) + XCTAssertTrue(sql.contains("PRIMARY KEY")) + XCTAssertTrue(sql.contains("'Users'")) + XCTAssertTrue(sql.contains("'dbo'")) + } + + func testIndexesQueryUsesBracketedIdentifier() { + let sql = MSSQLSchemaQueries.indexes(schema: "dbo", table: "Users") + XCTAssertTrue(sql.contains("OBJECT_ID('[dbo].[Users]')")) + XCTAssertTrue(sql.contains("sys.indexes")) + } + + func testForeignKeysQueryFiltersByTableAndSchema() { + let sql = MSSQLSchemaQueries.foreignKeys(schema: "dbo", table: "Orders") + XCTAssertTrue(sql.contains("sys.foreign_keys")) + XCTAssertTrue(sql.contains("'Orders'")) + XCTAssertTrue(sql.contains("'dbo'")) + } + + func testParseTableRowDetectsView() { + let row: [String?] = ["v_active_users", "VIEW"] + XCTAssertEqual(MSSQLSchemaQueries.parseTableRow(row), MSSQLTableRow(name: "v_active_users", isView: true)) + } + + func testParseTableRowDefaultsToTable() { + let row: [String?] = ["users", "BASE TABLE"] + XCTAssertEqual(MSSQLSchemaQueries.parseTableRow(row), MSSQLTableRow(name: "users", isView: false)) + } + + func testParseTableRowRejectsMissingName() { + XCTAssertNil(MSSQLSchemaQueries.parseTableRow([nil, "BASE TABLE"])) + } + + func testParseColumnRowExtractsAllFields() { + let row: [String?] = ["id", "int", nil, "10", "0", "NO", nil, "1", "1"] + let parsed = MSSQLSchemaQueries.parseColumnRow(row) + XCTAssertEqual(parsed?.name, "id") + XCTAssertEqual(parsed?.dataType, "int") + XCTAssertEqual(parsed?.isNullable, false) + XCTAssertEqual(parsed?.isIdentity, true) + XCTAssertEqual(parsed?.isPrimaryKey, true) + } + + func testColumnDisplayTypeForFixedSizeOmitsLength() { + let row: [String?] = ["id", "int", "4", nil, nil, "NO", nil, "0", "0"] + XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "int") + } + + func testColumnDisplayTypeForVarcharIncludesLength() { + let row: [String?] = ["name", "nvarchar", "100", nil, nil, "YES", nil, "0", "0"] + XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "nvarchar(100)") + } + + func testColumnDisplayTypeForMaxLengthRendersMax() { + let row: [String?] = ["body", "varchar", "-1", nil, nil, "YES", nil, "0", "0"] + XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "varchar(max)") + } + + func testColumnDisplayTypeForDecimalIncludesPrecisionScale() { + let row: [String?] = ["amount", "decimal", nil, "18", "2", "NO", nil, "0", "0"] + XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "decimal(18,2)") + } + + func testParseIndexRowExtractsFlags() { + let row: [String?] = ["IX_users_email", "1", "0", "email"] + let parsed = MSSQLSchemaQueries.parseIndexRow(row) + XCTAssertEqual(parsed?.name, "IX_users_email") + XCTAssertEqual(parsed?.isUnique, true) + XCTAssertEqual(parsed?.isPrimary, false) + XCTAssertEqual(parsed?.columnName, "email") + } + + func testParseIndexRowRejectsMissingColumn() { + XCTAssertNil(MSSQLSchemaQueries.parseIndexRow(["IX_x", "0", "0", nil])) + } + + func testParseForeignKeyRowExtractsAll() { + let row: [String?] = ["FK_orders_users", "user_id", "users", "id"] + let parsed = MSSQLSchemaQueries.parseForeignKeyRow(row) + XCTAssertEqual(parsed?.constraintName, "FK_orders_users") + XCTAssertEqual(parsed?.columnName, "user_id") + XCTAssertEqual(parsed?.referencedTable, "users") + XCTAssertEqual(parsed?.referencedColumn, "id") + } +} diff --git a/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h b/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h index 917609597..742367e5f 100644 --- a/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h +++ b/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h @@ -121,4 +121,7 @@ extern MHANDLEFUNC dbmsghandle(MHANDLEFUNC handler); extern char *dbversion(void); +// Global login timeout in seconds. Applies to all subsequent dblogin/dbopen calls. +extern RETCODE dbsetlogintime(int seconds); + #endif /* _SYBDB_H_ */ diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift new file mode 100644 index 000000000..40b32b06b --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -0,0 +1,530 @@ +// +// FreeTDSConnection.swift +// TablePro +// +// Dual-ownership: compiled into BOTH the macOS MSSQLDriver plugin target +// (Plugins/MSSQLDriverPlugin/ is its FileSystemSynchronizedRootGroup) AND the +// iOS TableProMobile target (via the cross-project file reference at +// TableProMobile/TableProMobile.xcodeproj path = ../Plugins/MSSQLDriverPlugin/...). +// Edits here ship to both platforms, so keep the API neutral (no PluginKit deps). +// + +import CFreeTDS +import Foundation +import os +import TableProMSSQLCore + +private let freetdsLogger = Logger(subsystem: "com.TablePro", category: "FreeTDSConnection") + +private let freetdsErrorLock = NSLock() +private var freetdsConnectionErrors: [UnsafeRawPointer: String] = [:] +private var freetdsGlobalError = "" + +private func freetdsGetError(for dbproc: UnsafeMutablePointer?) -> String { + freetdsErrorLock.lock() + defer { freetdsErrorLock.unlock() } + if let dbproc { + return freetdsConnectionErrors[UnsafeRawPointer(dbproc)] ?? freetdsGlobalError + } + return freetdsGlobalError +} + +private func freetdsClearError(for dbproc: UnsafeMutablePointer?) { + freetdsErrorLock.lock() + defer { freetdsErrorLock.unlock() } + if let dbproc { + freetdsConnectionErrors[UnsafeRawPointer(dbproc)] = nil + } else { + freetdsGlobalError = "" + } +} + +private func freetdsSetError(_ msg: String, for dbproc: UnsafeMutablePointer?, overwrite: Bool = false) { + freetdsErrorLock.lock() + defer { freetdsErrorLock.unlock() } + if let dbproc { + let key = UnsafeRawPointer(dbproc) + if overwrite || (freetdsConnectionErrors[key]?.isEmpty ?? true) { + freetdsConnectionErrors[key] = msg + } + } else if overwrite || freetdsGlobalError.isEmpty { + freetdsGlobalError = msg + } +} + +private func freetdsUnregister(_ dbproc: UnsafeMutablePointer) { + freetdsErrorLock.lock() + defer { freetdsErrorLock.unlock() } + freetdsConnectionErrors.removeValue(forKey: UnsafeRawPointer(dbproc)) +} + +private let freetdsInitOnce: Void = { + _ = dbinit() + _ = dberrhandle { dbproc, _, dberr, _, dberrstr, oserrstr in + var msg = "db-lib error \(dberr)" + if let s = dberrstr { msg += ": \(String(cString: s))" } + if let s = oserrstr, String(cString: s) != "Success" { msg += " (os: \(String(cString: s)))" } + freetdsLogger.error("FreeTDS: \(msg)") + freetdsSetError(msg, for: dbproc) + return INT_CANCEL + } + _ = dbmsghandle { dbproc, msgno, _, severity, msgtext, _, _, _ in + guard let text = msgtext else { return 0 } + let msg = String(cString: text) + if severity > 10 { + freetdsSetError(msg, for: dbproc, overwrite: true) + freetdsLogger.error("FreeTDS msg \(msgno) sev \(severity): \(msg)") + } else { + freetdsLogger.debug("FreeTDS msg \(msgno): \(msg)") + } + return 0 + } +}() + +private func freetdsDispatchAsync( + on queue: DispatchQueue, + execute work: @escaping @Sendable () throws -> T +) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + let result = try work() + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } +} + +private func freetdsDispatchAsync( + on queue: DispatchQueue, + execute work: @escaping @Sendable () throws -> Void +) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + queue.async { + do { + try work() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } +} + +// nonisolated so this file compiles cleanly under TableProMobile's +// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor build setting. The class manages its own +// thread safety via a private serial DispatchQueue and NSLock; no main-actor hop needed. +nonisolated final class FreeTDSConnection: @unchecked Sendable { + private var dbproc: UnsafeMutablePointer? + private let queue: DispatchQueue + private let options: MSSQLConnectionOptions + private let lock = NSLock() + private var _isConnected = false + private var _isCancelled = false + + var isConnected: Bool { + lock.lock() + defer { lock.unlock() } + return _isConnected + } + + init(options: MSSQLConnectionOptions) { + self.options = options + self.queue = DispatchQueue(label: "com.TablePro.freetds.\(options.host).\(options.port)", qos: .userInitiated) + _ = freetdsInitOnce + } + + func connect() async throws { + try await freetdsDispatchAsync(on: queue) { [self] in + try self.connectSync() + } + } + + private func connectSync() throws { + guard let login = dblogin() else { + throw MSSQLCoreError.connectionFailed("Failed to create login") + } + defer { dbloginfree(login) } + + _ = dbsetlname(login, options.user, Int32(DBSETUSER)) + _ = dbsetlname(login, options.password, Int32(DBSETPWD)) + _ = dbsetlname(login, options.applicationName, Int32(DBSETAPP)) + _ = dbsetlname(login, "us_english", Int32(DBSETNATLANG)) + _ = dbsetlname(login, "UTF-8", Int32(DBSETCHARSET)) + _ = dbsetlversion(login, UInt8(DBVERSION_74)) + _ = dbsetlname(login, options.encryptionFlag, Int32(DBSETENCRYPT)) + + // dbsetlogintime is process-global; setting before dbopen bounds this call. Concurrent + // connectSync from another FreeTDSConnection would race, but the serial connect queue and + // the brief window (cleared at function exit) keeps the cost acceptable for interactive use. + _ = dbsetlogintime(Int32(options.loginTimeoutSeconds)) + + freetdsClearError(for: nil) + let serverName = "\(options.host):\(options.port)" + guard let proc = dbopen(login, serverName) else { + let detail = freetdsGetError(for: nil) + let msg = detail.isEmpty ? "Check host, port, credentials, and TLS settings" : detail + throw MSSQLCoreError.connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)") + } + + if !options.database.isEmpty { + if dbuse(proc, options.database) == FAIL { + _ = dbclose(proc) + throw MSSQLCoreError.connectionFailed("Cannot open database '\(options.database)'") + } + } + + self.dbproc = proc + lock.lock() + _isConnected = true + lock.unlock() + } + + func switchDatabase(_ database: String) async throws { + try await freetdsDispatchAsync(on: queue) { [self] in + guard let proc = self.dbproc else { + throw MSSQLCoreError.notConnected + } + if dbuse(proc, database) == FAIL { + throw MSSQLCoreError.queryFailed("Cannot switch to database '\(database)'") + } + } + } + + func disconnect() { + let handle = dbproc + dbproc = nil + + lock.lock() + _isConnected = false + lock.unlock() + + if let handle { + freetdsUnregister(handle) + queue.async { + _ = dbclose(handle) + } + } + } + + func cancelCurrentQuery() { + lock.lock() + _isCancelled = true + let proc = dbproc + lock.unlock() + + guard let proc else { return } + dbcancel(proc) + } + + func executeQuery(_ query: String) async throws -> MSSQLRawResult { + let queryToRun = String(query) + return try await withTaskCancellationHandler { + try await freetdsDispatchAsync(on: queue) { [self] in + try self.executeQuerySync(queryToRun) + } + } onCancel: { [weak self] in + self?.cancelCurrentQuery() + } + } + + private func executeQuerySync(_ query: String) throws -> MSSQLRawResult { + guard let proc = dbproc else { + throw MSSQLCoreError.notConnected + } + + _ = dbcanquery(proc) + + lock.lock() + _isCancelled = false + lock.unlock() + + freetdsClearError(for: proc) + if dbcmd(proc, query) == FAIL { + throw MSSQLCoreError.queryFailed("Failed to prepare query") + } + if dbsqlexec(proc) == FAIL { + let detail = freetdsGetError(for: proc) + let msg = detail.isEmpty ? "Query execution failed" : detail + throw MSSQLCoreError.queryFailed(msg) + } + + var allColumns: [MSSQLColumnDescriptor] = [] + var allRows: [[MSSQLRawCell]] = [] + var firstResultSet = true + var truncated = false + + while true { + lock.lock() + let cancelledBetweenResults = _isCancelled + if cancelledBetweenResults { _isCancelled = false } + lock.unlock() + if cancelledBetweenResults { + throw CancellationError() + } + + let resCode = dbresults(proc) + if resCode == FAIL { + throw MSSQLCoreError.queryFailed("Query execution failed") + } + if resCode == Int32(NO_MORE_RESULTS) { + break + } + + let numCols = dbnumcols(proc) + if numCols <= 0 { continue } + + var descriptors: [MSSQLColumnDescriptor] = [] + for i in 1...numCols { + let name = dbcolname(proc, Int32(i)).map { String(cString: $0) } ?? "col\(i)" + let type = Self.columnType(fromFreeTDSToken: dbcoltype(proc, Int32(i))) + descriptors.append(MSSQLColumnDescriptor(name: name, type: type)) + } + + if firstResultSet { + allColumns = descriptors + firstResultSet = false + } + + while true { + let rowCode = dbnextrow(proc) + if rowCode == Int32(NO_MORE_ROWS) { break } + if rowCode == FAIL { break } + + lock.lock() + let cancelled = _isCancelled + if cancelled { _isCancelled = false } + lock.unlock() + if cancelled { + throw CancellationError() + } + + var row: [MSSQLRawCell] = [] + for i in 1...numCols { + let len = dbdatlen(proc, Int32(i)) + let colToken = dbcoltype(proc, Int32(i)) + let colType = descriptors[Int(i - 1)].type + if len <= 0 && colToken != Int32(SYBBIT) { + row.append(.null) + } else if let ptr = dbdata(proc, Int32(i)) { + if colType.isBinary { + row.append(.bytes(Data(bytes: ptr, count: Int(len)))) + } else if let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcToken: colToken, srcLen: len, type: colType) { + row.append(.string(str)) + } else { + row.append(.null) + } + } else { + row.append(.null) + } + } + allRows.append(row) + if allRows.count >= MSSQLRowLimits.emergencyMax { + truncated = true + break + } + } + } + + let affectedRows = allColumns.isEmpty ? 0 : allRows.count + return MSSQLRawResult( + columns: allColumns, + rows: allRows, + affectedRows: affectedRows, + isTruncated: truncated + ) + } + + func streamQuery( + _ query: String, + continuation: AsyncThrowingStream.Continuation + ) async throws { + let queryToRun = String(query) + try await withTaskCancellationHandler { + try await freetdsDispatchAsync(on: queue) { [self] in + try self.streamQuerySync(queryToRun, continuation: continuation) + } + } onCancel: { [weak self] in + self?.cancelCurrentQuery() + } + } + + private func streamQuerySync( + _ query: String, + continuation: AsyncThrowingStream.Continuation + ) throws { + guard let proc = dbproc else { + throw MSSQLCoreError.notConnected + } + + _ = dbcanquery(proc) + + lock.lock() + _isCancelled = false + lock.unlock() + + freetdsClearError(for: proc) + if dbcmd(proc, query) == FAIL { + throw MSSQLCoreError.queryFailed("Failed to prepare query") + } + if dbsqlexec(proc) == FAIL { + let detail = freetdsGetError(for: proc) + let msg = detail.isEmpty ? "Query execution failed" : detail + throw MSSQLCoreError.queryFailed(msg) + } + + var headerSent = false + var currentDescriptors: [MSSQLColumnDescriptor] = [] + + while true { + lock.lock() + let cancelledBetweenResults = _isCancelled || Task.isCancelled + if cancelledBetweenResults { _isCancelled = false } + lock.unlock() + if cancelledBetweenResults { + continuation.finish(throwing: CancellationError()) + return + } + + let resCode = dbresults(proc) + if resCode == FAIL { + continuation.finish(throwing: MSSQLCoreError.queryFailed("Query execution failed")) + return + } + if resCode == Int32(NO_MORE_RESULTS) { + break + } + + let numCols = dbnumcols(proc) + if numCols <= 0 { continue } + + if !headerSent { + var descriptors: [MSSQLColumnDescriptor] = [] + for i in 1...numCols { + let name = dbcolname(proc, Int32(i)).map { String(cString: $0) } ?? "col\(i)" + let type = Self.columnType(fromFreeTDSToken: dbcoltype(proc, Int32(i))) + descriptors.append(MSSQLColumnDescriptor(name: name, type: type)) + } + currentDescriptors = descriptors + continuation.yield(.header(columns: descriptors)) + headerSent = true + } + + var batch: [[MSSQLRawCell]] = [] + batch.reserveCapacity(MSSQLRowLimits.streamBatchSize) + + while true { + let rowCode = dbnextrow(proc) + if rowCode == Int32(NO_MORE_ROWS) { break } + if rowCode == FAIL { break } + + lock.lock() + let cancelled = _isCancelled || Task.isCancelled + if cancelled { _isCancelled = false } + lock.unlock() + if cancelled { + if !batch.isEmpty { + continuation.yield(.rows(batch)) + } + continuation.finish(throwing: CancellationError()) + return + } + + var row: [MSSQLRawCell] = [] + for i in 1...numCols { + let len = dbdatlen(proc, Int32(i)) + let colToken = dbcoltype(proc, Int32(i)) + let colType = currentDescriptors[Int(i - 1)].type + if len <= 0 && colToken != Int32(SYBBIT) { + row.append(.null) + } else if let ptr = dbdata(proc, Int32(i)) { + if colType.isBinary { + row.append(.bytes(Data(bytes: ptr, count: Int(len)))) + } else if let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcToken: colToken, srcLen: len, type: colType) { + row.append(.string(str)) + } else { + row.append(.null) + } + } else { + row.append(.null) + } + } + batch.append(row) + if batch.count >= MSSQLRowLimits.streamBatchSize { + continuation.yield(.rows(batch)) + batch.removeAll(keepingCapacity: true) + } + } + + if !batch.isEmpty { + continuation.yield(.rows(batch)) + } + } + + continuation.finish() + } + + static func columnType(fromFreeTDSToken token: Int32) -> MSSQLColumnType { + switch token { + case Int32(SYBCHAR): return .char + case Int32(SYBVARCHAR): return .varchar + case Int32(SYBTEXT): return .text + case Int32(SYBNCHAR): return .nchar + case Int32(SYBNVARCHAR): return .nvarchar + case Int32(SYBNTEXT): return .ntext + case Int32(SYBINT1): return .tinyInt + case Int32(SYBINT2): return .smallInt + case Int32(SYBINT4): return .int + case Int32(SYBINT8): return .bigInt + case Int32(SYBFLT8): return .float + case Int32(SYBREAL): return .real + case Int32(SYBDECIMAL), Int32(SYBNUMERIC): return .decimal + case Int32(SYBMONEY): return .money + case Int32(SYBMONEY4): return .smallMoney + case Int32(SYBBIT): return .bit + case Int32(SYBBINARY): return .binary + case Int32(SYBVARBINARY): return .varbinary + case Int32(SYBIMAGE): return .image + case Int32(SYBDATETIME): return .dateTime + case Int32(SYBDATETIME4): return .smallDateTime + case Int32(SYBDATETIMN): return .dateTimeN + case 40: return .date + case 41: return .time + case 42: return .dateTime2 + case 43: return .dateTimeOffset + case Int32(SYBUNIQUE): return .uniqueIdentifier + default: return .unknown(token) + } + } + + private static func columnValueAsString( + proc: UnsafeMutablePointer, + ptr: UnsafePointer, + srcToken: Int32, + srcLen: DBINT, + type: MSSQLColumnType + ) -> String? { + if type.isNarrowString { + return String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .utf8) + ?? String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .isoLatin1) + } + if type.isUnicodeString { + return String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .utf8) + ?? String(data: Data(bytes: ptr, count: Int(srcLen)), encoding: .utf16LittleEndian) + } + let bufSize: DBINT = 256 + var buf = [BYTE](repeating: 0, count: Int(bufSize)) + let converted = buf.withUnsafeMutableBufferPointer { bufPtr in + dbconvert(proc, srcToken, ptr, srcLen, Int32(SYBCHAR), bufPtr.baseAddress, bufSize) + } + guard converted > 0, + let raw = String(bytes: buf.prefix(Int(converted)), encoding: .utf8) + else { return nil } + if type.isDateOrTime { + return MSSQLDatetimeFormatter.reformat(raw, type: type) ?? raw + } + return raw + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 4acc0c9b8..96583bd98 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -3,11 +3,53 @@ // TablePro // -import CFreeTDS import Foundation import os +import TableProMSSQLCore import TableProPluginKit +// MARK: - Core ↔ Plugin Bridges + +private extension MSSQLRawCell { + var asPluginCell: PluginCellValue { + switch self { + case .null: return .null + case .string(let s): return PluginCellValue.fromOptional(s) + case .bytes(let d): return .bytes(d) + } + } +} + +private extension MSSQLRawResult { + func toPluginResult(executionTime: TimeInterval) -> PluginQueryResult { + PluginQueryResult( + columns: columns.map { $0.name }, + columnTypeNames: columns.map { $0.type.canonicalName }, + rows: rows.map { row in row.map { $0.asPluginCell } }, + rowsAffected: affectedRows, + executionTime: executionTime, + isTruncated: isTruncated + ) + } +} + +private extension MSSQLPluginError { + init(coreError: MSSQLCoreError) { + switch coreError { + case .notConnected: + self = .notConnected + case .connectionFailed(let m): + self = .connectionFailed(m) + case .queryFailed(let m): + self = .queryFailed(m) + case .cancelled: + self = .queryFailed(String(localized: "Query was cancelled")) + case .tlsHandshakeFailed(let m): + self = .connectionFailed(String(format: String(localized: "TLS: %@"), m)) + } + } +} + final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "MSSQL Driver" static let pluginVersion = "1.0.0" @@ -101,626 +143,6 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { } } -// MARK: - Global FreeTDS initialization - -/// Per-connection error storage keyed by DBPROCESS pointer. -/// Falls back to a global error string when the DBPROCESS is nil (pre-connection errors). -private let freetdsErrorLock = NSLock() -private var freetdsConnectionErrors: [UnsafeRawPointer: String] = [:] -private var freetdsGlobalError = "" - -private func freetdsGetError(for dbproc: UnsafeMutablePointer?) -> String { - freetdsErrorLock.lock() - defer { freetdsErrorLock.unlock() } - if let dbproc { - return freetdsConnectionErrors[UnsafeRawPointer(dbproc)] ?? freetdsGlobalError - } - return freetdsGlobalError -} - -private func freetdsClearError(for dbproc: UnsafeMutablePointer?) { - freetdsErrorLock.lock() - defer { freetdsErrorLock.unlock() } - if let dbproc { - freetdsConnectionErrors[UnsafeRawPointer(dbproc)] = nil - } else { - freetdsGlobalError = "" - } -} - -private func freetdsSetError(_ msg: String, for dbproc: UnsafeMutablePointer?, overwrite: Bool = false) { - freetdsErrorLock.lock() - defer { freetdsErrorLock.unlock() } - if let dbproc { - let key = UnsafeRawPointer(dbproc) - if overwrite || (freetdsConnectionErrors[key]?.isEmpty ?? true) { - freetdsConnectionErrors[key] = msg - } - } else if overwrite || freetdsGlobalError.isEmpty { - freetdsGlobalError = msg - } -} - -private func freetdsUnregister(_ dbproc: UnsafeMutablePointer) { - freetdsErrorLock.lock() - defer { freetdsErrorLock.unlock() } - freetdsConnectionErrors.removeValue(forKey: UnsafeRawPointer(dbproc)) -} - -private let freetdsLogger = Logger(subsystem: "com.TablePro", category: "FreeTDSConnection") - -private let freetdsInitOnce: Void = { - _ = dbinit() - _ = dberrhandle { dbproc, _, dberr, _, dberrstr, oserrstr in - var msg = "db-lib error \(dberr)" - if let s = dberrstr { msg += ": \(String(cString: s))" } - if let s = oserrstr, String(cString: s) != "Success" { msg += " (os: \(String(cString: s)))" } - freetdsLogger.error("FreeTDS: \(msg)") - freetdsSetError(msg, for: dbproc) - return INT_CANCEL - } - _ = dbmsghandle { dbproc, msgno, _, severity, msgtext, _, _, _ in - guard let text = msgtext else { return 0 } - let msg = String(cString: text) - if severity > 10 { - // SQL Server sends informational messages first, error messages last — - // overwrite so the most specific error is kept - freetdsSetError(msg, for: dbproc, overwrite: true) - freetdsLogger.error("FreeTDS msg \(msgno) sev \(severity): \(msg)") - } else { - freetdsLogger.debug("FreeTDS msg \(msgno): \(msg)") - } - return 0 - } -}() - -// MARK: - FreeTDS Connection - -private struct FreeTDSQueryResult { - let columns: [String] - let columnTypeNames: [String] - let rows: [[PluginCellValue]] - let affectedRows: Int - let isTruncated: Bool -} - -private final class FreeTDSConnection: @unchecked Sendable { - private var dbproc: UnsafeMutablePointer? - private let queue: DispatchQueue - private let host: String - private let port: Int - private let user: String - private let password: String - private let database: String - private let ssl: SSLConfiguration - private let lock = NSLock() - private var _isConnected = false - private var _isCancelled = false - - var isConnected: Bool { - lock.lock() - defer { lock.unlock() } - return _isConnected - } - - init( - host: String, - port: Int, - user: String, - password: String, - database: String, - ssl: SSLConfiguration - ) { - self.queue = DispatchQueue(label: "com.TablePro.freetds.\(host).\(port)", qos: .userInitiated) - self.host = host - self.port = port - self.user = user - self.password = password - self.database = database - self.ssl = ssl - _ = freetdsInitOnce - } - - func connect() async throws { - try await pluginDispatchAsync(on: queue) { [self] in - try self.connectSync() - } - } - - private func connectSync() throws { - guard let login = dblogin() else { - throw MSSQLPluginError.connectionFailed("Failed to create login") - } - defer { dbloginfree(login) } - - _ = dbsetlname(login, user, Int32(DBSETUSER)) - _ = dbsetlname(login, password, Int32(DBSETPWD)) - _ = dbsetlname(login, "TablePro", Int32(DBSETAPP)) - _ = dbsetlname(login, "us_english", Int32(DBSETNATLANG)) - _ = dbsetlname(login, "UTF-8", Int32(DBSETCHARSET)) - _ = dbsetlversion(login, UInt8(DBVERSION_74)) - _ = dbsetlname(login, MSSQLSSLMapping.freetdsEncryptionFlag(for: ssl.mode), Int32(DBSETENCRYPT)) - - freetdsClearError(for: nil) - let serverName = "\(host):\(port)" - guard let proc = dbopen(login, serverName) else { - let detail = freetdsGetError(for: nil) - let msg = detail.isEmpty ? "Check host, port, and credentials" : detail - throw MSSQLPluginError.connectionFailed("Failed to connect to \(host):\(port) — \(msg)") - } - - if !database.isEmpty { - if dbuse(proc, database) == FAIL { - _ = dbclose(proc) - throw MSSQLPluginError.connectionFailed("Cannot open database '\(database)'") - } - } - - self.dbproc = proc - lock.lock() - _isConnected = true - lock.unlock() - } - - func switchDatabase(_ database: String) async throws { - try await pluginDispatchAsync(on: queue) { [self] in - guard let proc = self.dbproc else { - throw MSSQLPluginError.notConnected - } - if dbuse(proc, database) == FAIL { - throw MSSQLPluginError.queryFailed("Cannot switch to database '\(database)'") - } - } - } - - func disconnect() { - let handle = dbproc - dbproc = nil - - lock.lock() - _isConnected = false - lock.unlock() - - if let handle = handle { - freetdsUnregister(handle) - queue.async { - _ = dbclose(handle) - } - } - } - - func cancelCurrentQuery() { - lock.lock() - _isCancelled = true - let proc = dbproc - lock.unlock() - - guard let proc else { return } - dbcancel(proc) - } - - func executeQuery(_ query: String) async throws -> FreeTDSQueryResult { - let queryToRun = String(query) - return try await pluginDispatchAsync(on: queue) { [self] in - try self.executeQuerySync(queryToRun) - } - } - - private func executeQuerySync(_ query: String) throws -> FreeTDSQueryResult { - guard let proc = dbproc else { - throw MSSQLPluginError.notConnected - } - - _ = dbcanquery(proc) - - lock.lock() - _isCancelled = false - lock.unlock() - - freetdsClearError(for: proc) - if dbcmd(proc, query) == FAIL { - throw MSSQLPluginError.queryFailed("Failed to prepare query") - } - if dbsqlexec(proc) == FAIL { - let detail = freetdsGetError(for: proc) - let msg = detail.isEmpty ? "Query execution failed" : detail - throw MSSQLPluginError.queryFailed(msg) - } - - var allColumns: [String] = [] - var allTypeNames: [String] = [] - var allRows: [[PluginCellValue]] = [] - var firstResultSet = true - var truncated = false - - while true { - lock.lock() - let cancelledBetweenResults = _isCancelled - if cancelledBetweenResults { _isCancelled = false } - lock.unlock() - if cancelledBetweenResults { - throw CancellationError() - } - - let resCode = dbresults(proc) - if resCode == FAIL { - throw MSSQLPluginError.queryFailed("Query execution failed") - } - if resCode == Int32(NO_MORE_RESULTS) { - break - } - - let numCols = dbnumcols(proc) - if numCols <= 0 { continue } - - var cols: [String] = [] - var typeNames: [String] = [] - for i in 1...numCols { - let name = dbcolname(proc, Int32(i)).map { String(cString: $0) } ?? "col\(i)" - cols.append(name) - typeNames.append(Self.freetdsTypeName(dbcoltype(proc, Int32(i)))) - } - - if firstResultSet { - allColumns = cols - allTypeNames = typeNames - firstResultSet = false - } - - while true { - let rowCode = dbnextrow(proc) - if rowCode == Int32(NO_MORE_ROWS) { break } - if rowCode == FAIL { break } - - lock.lock() - let cancelled = _isCancelled - if cancelled { _isCancelled = false } - lock.unlock() - if cancelled { - throw CancellationError() - } - - var row: [PluginCellValue] = [] - for i in 1...numCols { - let len = dbdatlen(proc, Int32(i)) - let colType = dbcoltype(proc, Int32(i)) - if len <= 0 && colType != Int32(SYBBIT) { - row.append(.null) - } else if let ptr = dbdata(proc, Int32(i)) { - if Self.isBinaryType(colType) { - row.append(.bytes(Data(bytes: ptr, count: Int(len)))) - } else { - let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcType: colType, srcLen: len) - row.append(PluginCellValue.fromOptional(str)) - } - } else { - row.append(.null) - } - } - allRows.append(row) - if allRows.count >= PluginRowLimits.emergencyMax { - truncated = true - break - } - } - } - - let affectedRows = allColumns.isEmpty ? 0 : allRows.count - return FreeTDSQueryResult( - columns: allColumns, - columnTypeNames: allTypeNames, - rows: allRows, - affectedRows: affectedRows, - isTruncated: truncated - ) - } - - func streamQuery( - _ query: String, - continuation: AsyncThrowingStream.Continuation - ) async throws { - let queryToRun = String(query) - try await pluginDispatchAsync(on: queue) { [self] in - try self.streamQuerySync(queryToRun, continuation: continuation) - } - } - - private func streamQuerySync( - _ query: String, - continuation: AsyncThrowingStream.Continuation - ) throws { - guard let proc = dbproc else { - throw MSSQLPluginError.notConnected - } - - _ = dbcanquery(proc) - - lock.lock() - _isCancelled = false - lock.unlock() - - freetdsClearError(for: proc) - if dbcmd(proc, query) == FAIL { - throw MSSQLPluginError.queryFailed("Failed to prepare query") - } - if dbsqlexec(proc) == FAIL { - let detail = freetdsGetError(for: proc) - let msg = detail.isEmpty ? "Query execution failed" : detail - throw MSSQLPluginError.queryFailed(msg) - } - - var headerSent = false - - while true { - lock.lock() - let cancelledBetweenResults = _isCancelled || Task.isCancelled - if cancelledBetweenResults { _isCancelled = false } - lock.unlock() - if cancelledBetweenResults { - continuation.finish(throwing: CancellationError()) - return - } - - let resCode = dbresults(proc) - if resCode == FAIL { - continuation.finish(throwing: MSSQLPluginError.queryFailed("Query execution failed")) - return - } - if resCode == Int32(NO_MORE_RESULTS) { - break - } - - let numCols = dbnumcols(proc) - if numCols <= 0 { continue } - - if !headerSent { - var cols: [String] = [] - var typeNames: [String] = [] - for i in 1...numCols { - let name = dbcolname(proc, Int32(i)).map { String(cString: $0) } ?? "col\(i)" - cols.append(name) - typeNames.append(Self.freetdsTypeName(dbcoltype(proc, Int32(i)))) - } - continuation.yield(.header(PluginStreamHeader( - columns: cols, - columnTypeNames: typeNames, - estimatedRowCount: nil - ))) - headerSent = true - } - - let batchSize = 5_000 - var batch: [PluginRow] = [] - batch.reserveCapacity(batchSize) - - while true { - let rowCode = dbnextrow(proc) - if rowCode == Int32(NO_MORE_ROWS) { break } - if rowCode == FAIL { break } - - lock.lock() - let cancelled = _isCancelled || Task.isCancelled - if cancelled { _isCancelled = false } - lock.unlock() - if cancelled { - if !batch.isEmpty { - continuation.yield(.rows(batch)) - } - continuation.finish(throwing: CancellationError()) - return - } - - var row: [PluginCellValue] = [] - for i in 1...numCols { - let len = dbdatlen(proc, Int32(i)) - let colType = dbcoltype(proc, Int32(i)) - if len <= 0 && colType != Int32(SYBBIT) { - row.append(.null) - } else if let ptr = dbdata(proc, Int32(i)) { - if Self.isBinaryType(colType) { - row.append(.bytes(Data(bytes: ptr, count: Int(len)))) - } else { - let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcType: colType, srcLen: len) - row.append(PluginCellValue.fromOptional(str)) - } - } else { - row.append(.null) - } - } - batch.append(row) - if batch.count >= batchSize { - continuation.yield(.rows(batch)) - batch.removeAll(keepingCapacity: true) - } - } - - if !batch.isEmpty { - continuation.yield(.rows(batch)) - } - } - - continuation.finish() - } - - private static func isBinaryType(_ srcType: Int32) -> Bool { - switch srcType { - case Int32(SYBBINARY), Int32(SYBVARBINARY), Int32(SYBIMAGE): - return true - default: - return false - } - } - - private static func columnValueAsString(proc: UnsafeMutablePointer, ptr: UnsafePointer, srcType: Int32, srcLen: DBINT) -> String? { - switch srcType { - case Int32(SYBCHAR), Int32(SYBVARCHAR), Int32(SYBTEXT): - return String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .utf8) - ?? String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .isoLatin1) - case Int32(SYBNCHAR), Int32(SYBNVARCHAR), Int32(SYBNTEXT): - // With client charset UTF-8, FreeTDS converts UTF-16 wire data to UTF-8 - // but may still report the original nvarchar type token - return String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .utf8) - ?? String(data: Data(bytes: ptr, count: Int(srcLen)), encoding: .utf16LittleEndian) - default: - let bufSize: DBINT = 256 - var buf = [BYTE](repeating: 0, count: Int(bufSize)) - let converted = buf.withUnsafeMutableBufferPointer { bufPtr in - dbconvert(proc, srcType, ptr, srcLen, Int32(SYBCHAR), bufPtr.baseAddress, bufSize) - } - guard converted > 0, - let raw = String(bytes: buf.prefix(Int(converted)), encoding: .utf8) - else { return nil } - return MSSQLDatetimeFormatter.reformat(raw, srcType: srcType) ?? raw - } - } - - private static func freetdsTypeName(_ type: Int32) -> String { - switch type { - case Int32(SYBCHAR), Int32(SYBVARCHAR): return "varchar" - case Int32(SYBNCHAR), Int32(SYBNVARCHAR): return "nvarchar" - case Int32(SYBTEXT): return "text" - case Int32(SYBNTEXT): return "ntext" - case Int32(SYBINT1): return "tinyint" - case Int32(SYBINT2): return "smallint" - case Int32(SYBINT4): return "int" - case Int32(SYBINT8): return "bigint" - case Int32(SYBFLT8): return "float" - case Int32(SYBREAL): return "real" - case Int32(SYBDECIMAL), Int32(SYBNUMERIC): return "decimal" - case Int32(SYBMONEY), Int32(SYBMONEY4): return "money" - case Int32(SYBBIT): return "bit" - case Int32(SYBBINARY), Int32(SYBVARBINARY): return "varbinary" - case Int32(SYBIMAGE): return "image" - case Int32(SYBDATETIME), Int32(SYBDATETIMN): return "datetime" - case Int32(SYBDATETIME4): return "smalldatetime" - case Int32(SYBUNIQUE): return "uniqueidentifier" - default: return "unknown" - } - } -} - -// MARK: - Datetime Reformatting - -/// Reformats FreeTDS msdblib datetime output into ISO 8601 so values round-trip -/// through SQL Server's implicit string-to-datetime conversion. -/// -/// FreeTDS dbconvert(... SYBCHAR) emits legacy datetime values as -/// "MMM d yyyy h:mm[:ss[:fffffff]]AM/PM" (msdblib mode). SQL Server's parser -/// rejects that format on subsequent UPDATE/WHERE binding. ISO 8601 -/// (yyyy-MM-dd HH:mm:ss[.fffffff]) parses everywhere and preserves the original -/// fractional digits exactly without Foundation.Date precision loss. -internal enum MSSQLDatetimeFormatter { - /// Reformats a FreeTDS-emitted column value when the source type is one of - /// SQL Server's datetime variants. Returns nil for non-datetime types so the - /// caller falls back to the raw FreeTDS string. - static func reformat(_ raw: String, srcType: Int32) -> String? { - switch srcType { - case Int32(SYBDATETIME), Int32(SYBDATETIME4), Int32(SYBDATETIMN): - break - case 40, 41, 42: - // SYBMSDATE (40), SYBMSTIME (41), SYBMSDATETIME2 (42) from TDS 7.3+. - // Constants are not declared in the CFreeTDS stub header; matched - // by raw value. SYBMSDATETIMEOFFSET (43) is intentionally excluded - // because the offset suffix format is not verified. - break - default: - return nil - } - return parse(raw) - } - - /// Returns ISO 8601 if the input is recognized, nil otherwise. Already-ISO - /// inputs pass through verbatim. Public so tests can exercise it directly. - 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) - } - - /// FreeTDS emits "yyyy-MM-dd ..." for some TDS 7.3+ types. Detect the prefix - /// and pass through, since the rest of the value is already SQL Server parseable. - 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 - } - - /// Parses "MMM d yyyy h:mm[:ss[:fff[fffff]]] AM|PM" (msdblib 12-hour) or the - /// 24-hour variant without an AM/PM marker. Returns ISO 8601 with fractional - /// digits preserved verbatim. - 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 } -} - // MARK: - MSSQL Plugin Driver final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { @@ -794,18 +216,24 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Connection func connect() async throws { - let conn = FreeTDSConnection( + let options = MSSQLConnectionOptions( host: config.host, port: config.port, user: config.username, password: config.password, database: config.database, - ssl: config.ssl + schema: _currentSchema, + encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode) ) - try await conn.connect() + let conn = FreeTDSConnection(options: options) + do { + try await conn.connect() + } catch let error as MSSQLCoreError { + throw MSSQLPluginError(coreError: error) + } self.freeTDSConn = conn - if let result = try? await conn.executeQuery("SELECT SCHEMA_NAME()"), + if let result = try? await executeInternal("SELECT SCHEMA_NAME()"), let serverSchema = result.rows.first?.first?.asText, !serverSchema.isEmpty { _currentSchema = serverSchema @@ -818,12 +246,25 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _currentSchema = formSchema } - if let result = try? await conn.executeQuery("SELECT @@VERSION"), + if let result = try? await executeInternal("SELECT @@VERSION"), let versionStr = result.rows.first?.first?.asText { _serverVersion = String(versionStr.prefix(50)) } } + private func executeInternal(_ query: String) async throws -> PluginQueryResult { + guard let conn = freeTDSConn else { + throw MSSQLPluginError.notConnected + } + let startTime = Date() + do { + let raw = try await conn.executeQuery(query) + return raw.toPluginResult(executionTime: Date().timeIntervalSince(startTime)) + } catch let error as MSSQLCoreError { + throw MSSQLPluginError(coreError: error) + } + } + func disconnect() { freeTDSConn?.disconnect() freeTDSConn = nil @@ -842,19 +283,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Query Execution func execute(query: String) async throws -> PluginQueryResult { - guard let conn = freeTDSConn else { - throw MSSQLPluginError.notConnected - } - let startTime = Date() - let result = try await conn.executeQuery(query) - return PluginQueryResult( - columns: result.columns, - columnTypeNames: result.columnTypeNames, - rows: result.rows, - rowsAffected: result.affectedRows, - executionTime: Date().timeIntervalSince(startTime), - isTruncated: result.isTruncated - ) + try await executeInternal(query) } // MARK: - DML Statement Generation @@ -1025,8 +454,36 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in let streamTask = Task { + let coreStream = AsyncThrowingStream { coreContinuation in + Task { + do { + try await conn.streamQuery(query, continuation: coreContinuation) + } catch let error as MSSQLCoreError { + coreContinuation.finish(throwing: MSSQLPluginError(coreError: error)) + } catch { + coreContinuation.finish(throwing: error) + } + } + } do { - try await conn.streamQuery(query, continuation: continuation) + for try await element in coreStream { + switch element { + case .header(let columns): + continuation.yield(.header(PluginStreamHeader( + columns: columns.map { $0.name }, + columnTypeNames: columns.map { $0.type.canonicalName }, + estimatedRowCount: nil + ))) + case .rows(let batch): + let pluginRows: [PluginRow] = batch.map { row in + row.map { $0.asPluginCell } + } + continuation.yield(.rows(pluginRows)) + case .affectedRows: + break + } + } + continuation.finish() } catch { continuation.finish(throwing: error) } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 2afea6dd3..1d578deec 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; }; 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3A69B82F976F38000AC5B2 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */; }; @@ -727,6 +728,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */, 5A864000A00000000 /* TableProPluginKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1200,6 +1202,9 @@ 5A864000500000000 /* Plugins/MSSQLDriverPlugin */, ); name = MSSQLDriver; + packageProductDependencies = ( + 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */, + ); productName = MSSQLDriver; productReference = 5A864000100000000 /* MSSQLDriver.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -4110,6 +4115,11 @@ isa = XCSwiftPackageProductDependency; productName = TableProAnalytics; }; + 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + productName = TableProMSSQLCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */; diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index c6ce415b8..d55891cb8 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5AD1F2002FB5500000000002 /* FreeTDSConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AD1F2002FB5500000000001 /* FreeTDSConnection.swift */; }; 5A72D6232F97A69500E2ADE0 /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 5A72D6222F97A69500E2ADE0 /* Secrets.xcconfig */; }; 5A7E81B12F95F23600EEF236 /* TableProAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 5A87EEED2F7F893000D028D1 /* TableProAnalytics */; }; 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */ = {isa = PBXBuildFile; productRef = 5A87EEEC2F7F893000D028D0 /* TableProSync */; }; @@ -24,6 +25,8 @@ 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EA2F7C1D03001F3337 /* TableProModels */; }; 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */; }; 5AB9F3EF2F7C1D03001F3337 /* TableProQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */; }; + 5AD1F1B62FB4455700296783 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1F1B52FB4455700296783 /* TableProMSSQLCore */; }; + 5AD1F1B82FB4456900296783 /* FreeTDS.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AD1F1B72FB4456900296783 /* FreeTDS.xcframework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,6 +61,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 5AD1F2002FB5500000000001 /* FreeTDSConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FreeTDSConnection.swift; path = ../Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift; sourceTree = ""; }; 5A72D6222F97A69500E2ADE0 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; 5A87ECDD2F7F88F200D028D0 /* AIPromptTemplates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIPromptTemplates.swift; sourceTree = ""; }; 5A87ECDE2F7F88F200D028D0 /* AIPromptTemplates+InlineSuggest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AIPromptTemplates+InlineSuggest.swift"; sourceTree = ""; }; @@ -526,6 +530,7 @@ 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = LibSSH2.xcframework; path = ../Libs/ios/LibSSH2.xcframework; sourceTree = ""; }; 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TableProMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5AC8A8F82FAFC99F005DE2A3 /* TableProMobileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5AD1F1B72FB4456900296783 /* FreeTDS.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = FreeTDS.xcframework; path = ../Libs/ios/FreeTDS.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -604,6 +609,8 @@ 5AA3133E2F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework in Frameworks */, 5AA313442F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework in Frameworks */, 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */, + 5AD1F1B82FB4456900296783 /* FreeTDS.xcframework in Frameworks */, + 5AD1F1B62FB4455700296783 /* TableProMSSQLCore in Frameworks */, 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */, 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */, ); @@ -1628,6 +1635,7 @@ 5AA313332F7EA5B4008EBA97 /* Frameworks */ = { isa = PBXGroup; children = ( + 5AD1F1B72FB4456900296783 /* FreeTDS.xcframework */, 5A87EEEB2F7F891F00D028D0 /* TableProSync */, 5A87EEE42F7F88F200D028D0 /* TablePro */, 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */, @@ -1716,6 +1724,7 @@ 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */, 5A87EEEC2F7F893000D028D0 /* TableProSync */, 5A87EEED2F7F893000D028D1 /* TableProAnalytics */, + 5AD1F1B52FB4455700296783 /* TableProMSSQLCore */, ); productName = TableProMobile; productReference = 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */; @@ -1827,6 +1836,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5AD1F2002FB5500000000002 /* FreeTDSConnection.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2075,7 +2085,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2 $(SRCROOT)/TableProMobile/CBridges/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2117,7 +2127,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2 $(SRCROOT)/TableProMobile/CBridges/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2139,7 +2149,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2 $(SRCROOT)/TableProMobile/CBridges/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2162,7 +2172,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2 $(SRCROOT)/TableProMobile/CBridges/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2249,6 +2259,11 @@ package = 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */; productName = TableProQuery; }; + 5AD1F1B52FB4455700296783 /* TableProMSSQLCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */; + productName = TableProMSSQLCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5AB9F3D12F7C1C12001F3337 /* Project object */; diff --git a/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h b/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h new file mode 100644 index 000000000..fce21d9a7 --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h @@ -0,0 +1,7 @@ +#ifndef CFreeTDS_h +#define CFreeTDS_h + +#include +#include + +#endif diff --git a/TableProMobile/TableProMobile/CBridges/CFreeTDS/module.modulemap b/TableProMobile/TableProMobile/CBridges/CFreeTDS/module.modulemap new file mode 100644 index 000000000..e72f3e25f --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CFreeTDS/module.modulemap @@ -0,0 +1,4 @@ +module CFreeTDS [system] { + header "CFreeTDS.h" + export * +} diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 2995d9ae5..aede448f5 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -49,11 +49,13 @@ final class ConnectionCoordinator { var supportsDatabaseSwitching: Bool { connection.type == .mysql || connection.type == .mariadb || - connection.type == .postgresql || connection.type == .redshift + connection.type == .postgresql || connection.type == .redshift || + connection.type == .mssql } var supportsSchemas: Bool { - connection.type == .postgresql || connection.type == .redshift + connection.type == .postgresql || connection.type == .redshift || + connection.type == .mssql } init(connection: DatabaseConnection, appState: AppState) { diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift new file mode 100644 index 000000000..36a1d0413 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -0,0 +1,292 @@ +import Foundation +import TableProDatabase +import TableProModels +import TableProMSSQLCore + +private extension MSSQLRawResult { + nonisolated func toQueryResult(executionTime: TimeInterval) -> QueryResult { + let columnInfos = columns.enumerated().map { idx, col in + ColumnInfo(name: col.name, typeName: col.type.canonicalName, ordinalPosition: idx) + } + return QueryResult( + columns: columnInfos, + rows: rows.map { row in row.map { $0.stringValue } }, + rowsAffected: affectedRows, + executionTime: executionTime, + isTruncated: isTruncated + ) + } +} + +final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { + private let conn: FreeTDSConnection + private let host: String + + var supportsSchemas: Bool { true } + var supportsTransactions: Bool { true } + + nonisolated(unsafe) private(set) var currentSchema: String? = "dbo" + nonisolated(unsafe) private(set) var serverVersion: String? + + init(connection: DatabaseConnection, password: String?) { + let options = MSSQLConnectionOptions( + host: connection.host, + port: connection.port, + user: connection.username, + password: password ?? "", + database: connection.database, + schema: MSSQLConnectionOptions.schema(from: connection.additionalFields), + encryptionFlag: Self.freetdsEncryptionFlag(for: connection.sslConfiguration), + loginTimeoutSeconds: Int(connection.additionalFields["mssqlLoginTimeout"] ?? "") ?? MSSQLConnectionOptions.defaultLoginTimeoutSeconds + ) + self.conn = FreeTDSConnection(options: options) + self.host = connection.host + self.currentSchema = options.schema + } + + private static func freetdsEncryptionFlag(for ssl: SSLConfiguration?) -> String { + guard let mode = ssl?.mode else { return "off" } + switch mode { + case .disable: return "off" + case .require: return "require" + case .verifyCa, .verifyFull: return "require" + } + } + + private var escapedSchema: String { + (currentSchema ?? "dbo").replacingOccurrences(of: "'", with: "''") + } + + // MARK: - Connection + + func connect() async throws { + try await LocalNetworkPermission.shared.ensureAccess(for: host) + do { + try await conn.connect() + } catch let error as MSSQLCoreError { + throw mapToConnectionError(error) + } + + if let serverSchema = try? await runQuery(MSSQLSchemaQueries.currentSchema).rows.first?.first ?? nil, + !serverSchema.isEmpty { + currentSchema = serverSchema + } + + if let version = try? await runQuery(MSSQLSchemaQueries.serverVersion).rows.first?.first ?? nil { + serverVersion = String(version.prefix(50)) + } + } + + func disconnect() async throws { + conn.disconnect() + } + + func ping() async throws -> Bool { + _ = try await runQuery(MSSQLSchemaQueries.ping) + return true + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> QueryResult { + try await runQuery(query) + } + + private func runQuery(_ query: String) async throws -> QueryResult { + let startTime = Date() + do { + let raw = try await conn.executeQuery(query) + return raw.toQueryResult(executionTime: Date().timeIntervalSince(startTime)) + } catch let error as MSSQLCoreError { + throw mapToConnectionError(error) + } + } + + func cancelCurrentQuery() async throws { + conn.cancelCurrentQuery() + } + + func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream { + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let task = Task { + let coreStream = AsyncThrowingStream { coreContinuation in + Task { + do { + try await conn.streamQuery(query, continuation: coreContinuation) + } catch { + coreContinuation.finish(throwing: error) + } + } + } + var emitted = 0 + var headerColumns: [ColumnInfo] = [] + do { + for try await element in coreStream { + if Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + return + } + switch element { + case .header(let columns): + headerColumns = columns.enumerated().map { idx, col in + ColumnInfo(name: col.name, typeName: col.type.canonicalName, ordinalPosition: idx) + } + continuation.yield(.columns(headerColumns)) + case .rows(let batch): + for row in batch { + if emitted >= options.maxRows { + continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + continuation.finish() + return + } + let cells = zip(headerColumns, row).map { columnInfo, rawCell in + cell(from: rawCell, columnTypeName: columnInfo.typeName, options: options) + } + continuation.yield(.row(Row(cells: cells))) + emitted += 1 + } + case .affectedRows(let count): + continuation.yield(.rowsAffected(count)) + } + } + continuation.finish() + } catch let error as MSSQLCoreError { + continuation.finish(throwing: mapToConnectionError(error)) + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + private func cell(from raw: MSSQLRawCell, columnTypeName: String, options: StreamOptions) -> Cell { + switch raw { + case .null: + return .null + case .string(let value): + return Cell.from(legacyValue: value, columnTypeName: columnTypeName, options: options) + case .bytes(let data): + return .binary(byteCount: data.count, ref: nil) + } + } + + // MARK: - Transactions + + func beginTransaction() async throws { + _ = try await runQuery(MSSQLSchemaQueries.beginTransaction) + } + + func commitTransaction() async throws { + _ = try await runQuery(MSSQLSchemaQueries.commitTransaction) + } + + func rollbackTransaction() async throws { + _ = try await runQuery(MSSQLSchemaQueries.rollbackTransaction) + } + + // MARK: - Database & Schema Navigation + + func fetchDatabases() async throws -> [String] { + let result = try await runQuery(MSSQLSchemaQueries.databases) + return result.rows.compactMap { $0.first ?? nil } + } + + func switchDatabase(to name: String) async throws { + do { + try await conn.switchDatabase(name) + } catch let error as MSSQLCoreError { + throw mapToConnectionError(error) + } + } + + func fetchSchemas() async throws -> [String] { + let result = try await runQuery(MSSQLSchemaQueries.schemas) + return result.rows.compactMap { $0.first ?? nil } + } + + func switchSchema(to name: String) async throws { + currentSchema = name + } + + // MARK: - Table Metadata + + private var effectiveSchema: String { currentSchema ?? "dbo" } + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let result = try await runQuery(MSSQLSchemaQueries.tables(schema: schema ?? effectiveSchema)) + return result.rows.compactMap { row in + MSSQLSchemaQueries.parseTableRow(row).map { + TableInfo(name: $0.name, type: $0.isView ? .view : .table) + } + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + let result = try await runQuery(MSSQLSchemaQueries.columns(schema: schema ?? effectiveSchema, table: table)) + return result.rows.enumerated().compactMap { idx, row in + MSSQLSchemaQueries.parseColumnRow(row).map { parsed in + ColumnInfo( + name: parsed.name, + typeName: parsed.displayType, + isPrimaryKey: parsed.isPrimaryKey, + isNullable: parsed.isNullable, + defaultValue: parsed.defaultValue, + characterMaxLength: parsed.characterMaxLength, + ordinalPosition: idx + ) + } + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { + let result = try await runQuery(MSSQLSchemaQueries.indexes(schema: schema ?? effectiveSchema, table: table)) + var byName: [String: (unique: Bool, primary: Bool, cols: [String])] = [:] + for row in result.rows { + guard let parsed = MSSQLSchemaQueries.parseIndexRow(row) else { continue } + if byName[parsed.name] == nil { + byName[parsed.name] = (parsed.isUnique, parsed.isPrimary, []) + } + byName[parsed.name]?.cols.append(parsed.columnName) + } + return byName.map { name, info in + IndexInfo(name: name, columns: info.cols, isUnique: info.unique, isPrimary: info.primary, type: "CLUSTERED") + }.sorted { $0.name < $1.name } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + let result = try await runQuery(MSSQLSchemaQueries.foreignKeys(schema: schema ?? effectiveSchema, table: table)) + return result.rows.compactMap { row in + MSSQLSchemaQueries.parseForeignKeyRow(row).map { + ForeignKeyInfo(name: $0.constraintName, column: $0.columnName, + referencedTable: $0.referencedTable, referencedColumn: $0.referencedColumn) + } + } + } + + // MARK: - Error Mapping + + private func mapToConnectionError(_ error: MSSQLCoreError) -> Error { + switch error { + case .notConnected: + return ConnectionError.notConnected + case .connectionFailed(let msg): + return DatabaseError(message: msg) + case .tlsHandshakeFailed(let msg): + return DatabaseError(message: "TLS handshake failed: \(msg)") + case .queryFailed(let msg): + return DatabaseError(message: msg) + case .cancelled: + return CancellationError() + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift index 52971bd05..a7c17b3f5 100644 --- a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift +++ b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift @@ -8,6 +8,7 @@ extension DatabaseType { case .postgresql: return "5432" case .redshift: return "5439" case .redis: return "6379" + case .mssql: return "1433" case .sqlite: return "" default: return "3306" } @@ -21,6 +22,7 @@ extension DatabaseType { case .redshift: "Redshift" case .sqlite: "SQLite" case .redis: "Redis" + case .mssql: "SQL Server" default: rawValue.uppercased() } } @@ -30,6 +32,7 @@ extension DatabaseType { .mariadb, .postgresql, .sqlite, - .redis + .redis, + .mssql ] } diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index ffd5e551f..f59f8c27b 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -10,11 +10,24 @@ enum SQLBuilder { return "`\(name.replacingOccurrences(of: "`", with: "``"))`" case .postgresql, .redshift: return "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + case .mssql: + return "[\(name.replacingOccurrences(of: "]", with: "]]"))]" default: return "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" } } + static func paginationClause(orderBy: String, limit: Int, offset: Int, for type: DatabaseType) -> String { + switch type { + case .mssql: + let order = orderBy.isEmpty ? "ORDER BY (SELECT NULL)" : orderBy + return "\(order) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + default: + let trailing = "LIMIT \(limit) OFFSET \(offset)" + return orderBy.isEmpty ? trailing : "\(orderBy) \(trailing)" + } + } + static func escapeString(_ value: String) -> String { value .replacingOccurrences(of: "\\", with: "\\\\") @@ -32,7 +45,8 @@ enum SQLBuilder { static func buildSelect(table: String, type: DatabaseType, limit: Int, offset: Int) -> String { let quoted = quoteIdentifier(table, for: type) - return "SELECT * FROM \(quoted) LIMIT \(limit) OFFSET \(offset)" + let pagination = paginationClause(orderBy: "", limit: limit, offset: offset, for: type) + return "SELECT * FROM \(quoted) \(pagination)" } static func buildDelete( @@ -87,8 +101,8 @@ enum SQLBuilder { ) -> String { let quoted = quoteIdentifier(table, for: type) let orderBy = buildOrderByClause(sortState, for: type) - return "SELECT * FROM \(quoted) \(orderBy) LIMIT \(limit) OFFSET \(offset)" - .replacingOccurrences(of: " ", with: " ") + let pagination = paginationClause(orderBy: orderBy, limit: limit, offset: offset, for: type) + return "SELECT * FROM \(quoted) \(pagination)" } static func buildFilteredSelect( @@ -100,10 +114,11 @@ enum SQLBuilder { let generator = FilterSQLGenerator(dialect: dialect) let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) let quoted = quoteIdentifier(table, for: type) - if whereClause.isEmpty { - return "SELECT * FROM \(quoted) LIMIT \(limit) OFFSET \(offset)" - } - return "SELECT * FROM \(quoted) \(whereClause) LIMIT \(limit) OFFSET \(offset)" + let pagination = paginationClause(orderBy: "", limit: limit, offset: offset, for: type) + var sql = "SELECT * FROM \(quoted)" + if !whereClause.isEmpty { sql += " \(whereClause)" } + sql += " \(pagination)" + return sql } static func buildFilteredSelect( @@ -117,10 +132,10 @@ enum SQLBuilder { let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) let orderBy = buildOrderByClause(sortState, for: type) let quoted = quoteIdentifier(table, for: type) + let pagination = paginationClause(orderBy: orderBy, limit: limit, offset: offset, for: type) var sql = "SELECT * FROM \(quoted)" if !whereClause.isEmpty { sql += " \(whereClause)" } - if !orderBy.isEmpty { sql += " \(orderBy)" } - sql += " LIMIT \(limit) OFFSET \(offset)" + sql += " \(pagination)" return sql } @@ -152,11 +167,11 @@ enum SQLBuilder { searchText: searchText, searchColumns: searchColumns, filters: filters, logicMode: logicMode, type: type ) + let orderBy = buildOrderByClause(sortState, for: type) + let pagination = paginationClause(orderBy: orderBy, limit: limit, offset: offset, for: type) var sql = "SELECT * FROM \(quoted)" if !whereClause.isEmpty { sql += " \(whereClause)" } - let orderBy = buildOrderByClause(sortState, for: type) - if !orderBy.isEmpty { sql += " \(orderBy)" } - sql += " LIMIT \(limit) OFFSET \(offset)" + sql += " \(pagination)" return sql } @@ -279,6 +294,14 @@ enum SQLBuilder { dataTypes: [], likeEscapeStyle: .explicit ) + case .mssql: + return SQLDialectDescriptor( + identifierQuote: "[", + keywords: [], + functions: [], + dataTypes: [], + likeEscapeStyle: .explicit + ) default: return SQLDialectDescriptor( identifierQuote: "\"", diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index c32363a0f..c36f19dee 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -34,12 +34,14 @@ final class IOSDriverFactory: DriverFactory { database: dbIndex, sslEnabled: connection.sslEnabled ) + case .mssql: + return MSSQLDriver(connection: connection, password: password) default: throw ConnectionError.driverNotFound(connection.type.rawValue) } } func supportedTypes() -> [DatabaseType] { - [.sqlite, .mysql, .mariadb, .postgresql, .redshift, .redis] + [.sqlite, .mysql, .mariadb, .postgresql, .redshift, .redis, .mssql] } } diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 8a580eea9..3342d8174 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -30,6 +30,7 @@ final class ConnectionFormViewModel { var password = "" var database = "" var sslEnabled = false + var mssqlSSLMode: SSLConfiguration.SSLMode = .disable // Organization var groupId: UUID? @@ -72,6 +73,10 @@ final class ConnectionFormViewModel { username = conn.username database = conn.database sslEnabled = conn.sslEnabled + // Coerce verify modes to .require: FreeTDS doesn't honor per-connection cert verification + // (MSSQLSSLMapping treats verify* as "require"). Matches what the driver actually does. + let storedMode = conn.sslConfiguration?.mode ?? .disable + mssqlSSLMode = (storedMode == .verifyCa || storedMode == .verifyFull) ? .require : storedMode sshEnabled = conn.sshEnabled groupId = conn.groupId tagId = conn.tagId @@ -318,10 +323,13 @@ final class ConnectionFormViewModel { username: username, database: database, sshEnabled: sshEnabled, - sslEnabled: sslEnabled, + sslEnabled: type == .mssql ? (mssqlSSLMode != .disable) : sslEnabled, groupId: groupId, tagId: tagId ) + if type == .mssql { + conn.sslConfiguration = SSLConfiguration(mode: mssqlSSLMode) + } conn.safeModeLevel = safeModeLevel if sshEnabled { conn.sshConfiguration = SSHConfiguration( diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 6425eb9f9..0c36a5220 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -157,12 +157,13 @@ final class DataBrowserViewModel { } private func buildSelectQuery(table: TableInfo) -> String { + let activeSort = effectiveSortState() if hasActiveSearch { return SQLBuilder.buildSearchSelect( table: table.name, type: databaseType, searchText: activeSearchText, searchColumns: searchableColumns(), filters: filters, logicMode: filterLogicMode, - sortState: sortState, + sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) } @@ -170,14 +171,14 @@ final class DataBrowserViewModel { return SQLBuilder.buildFilteredSelect( table: table.name, type: databaseType, filters: filters, logicMode: filterLogicMode, - sortState: sortState, + sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) } - if sortState.isSorting { + if activeSort.isSorting { return SQLBuilder.buildSelect( table: table.name, type: databaseType, - sortState: sortState, + sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) } @@ -187,6 +188,17 @@ final class DataBrowserViewModel { ) } + /// SQL Server's `OFFSET FETCH` requires `ORDER BY`. When the user has not picked an explicit + /// sort, inject a stable order (first primary-key column, falling back to the first column) + /// so paging is deterministic across requests. Other databases tolerate an empty ORDER BY. + private func effectiveSortState() -> SortState { + if sortState.isSorting { return sortState } + guard databaseType == .mssql else { return sortState } + let fallback = columnDetails.first(where: \.isPrimaryKey)?.name ?? columnDetails.first?.name + guard let fallback else { return sortState } + return SortState(columns: [SortColumn(name: fallback, ascending: true)]) + } + private func searchableColumns() -> [ColumnInfo] { columns.filter { col in let upper = col.typeName.uppercased() @@ -337,14 +349,20 @@ final class DataBrowserViewModel { // MARK: - Lazy Cell Loading - func loadFullValue(driver: DatabaseDriver, ref: CellRef) async throws -> String? { + func loadFullValue(driver: DatabaseDriver, ref: CellRef, databaseType: DatabaseType) async throws -> String? { let predicates = ref.primaryKey.map { component in - "\"\(component.column.replacingOccurrences(of: "\"", with: "\"\""))\" = '\(component.value.replacingOccurrences(of: "'", with: "''"))'" + "\(SQLBuilder.quoteIdentifier(component.column, for: databaseType)) = '\(component.value.replacingOccurrences(of: "'", with: "''"))'" } let predicate = predicates.joined(separator: " AND ") - let column = "\"\(ref.column.replacingOccurrences(of: "\"", with: "\"\""))\"" - let table = "\"\(ref.table.replacingOccurrences(of: "\"", with: "\"\""))\"" - let query = "SELECT \(column) FROM \(table) WHERE \(predicate) LIMIT 1" + let column = SQLBuilder.quoteIdentifier(ref.column, for: databaseType) + let table = SQLBuilder.quoteIdentifier(ref.table, for: databaseType) + let query: String + switch databaseType { + case .mssql: + query = "SELECT TOP 1 \(column) FROM \(table) WHERE \(predicate)" + default: + query = "SELECT \(column) FROM \(table) WHERE \(predicate) LIMIT 1" + } let result = try await driver.execute(query: query) return result.rows.first?.first ?? nil diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 9cb64455b..e042797a9 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -56,7 +56,17 @@ struct ConnectionFormView: View { if viewModel.type != .sqlite { Section { - Toggle("SSL", isOn: $viewModel.sslEnabled) + if viewModel.type == .mssql { + // FreeTDS db-lib only honors on/off encryption (DBSETENCRYPT). Per-connection + // cert chain verification is not exposed, so only Disabled and Required are listed. + // See Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift for the FreeTDS contract. + Picker(String(localized: "SSL Mode"), selection: $viewModel.mssqlSSLMode) { + Text(String(localized: "Disabled")).tag(SSLConfiguration.SSLMode.disable) + Text(String(localized: "Required")).tag(SSLConfiguration.SSLMode.require) + } + } else { + Toggle("SSL", isOn: $viewModel.sslEnabled) + } } sshSection(viewModel: viewModel) } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index aca043952..e25a039ed 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -251,7 +251,7 @@ struct DataBrowserView: View { onSaved: { Task { await viewModel.load() } }, loadFullValue: { ref in guard let session else { return nil } - return try await viewModel.loadFullValue(driver: session.driver, ref: ref) + return try await viewModel.loadFullValue(driver: session.driver, ref: ref, databaseType: connection.type) } ) } label: { diff --git a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift new file mode 100644 index 000000000..189f3a976 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift @@ -0,0 +1,277 @@ +import XCTest +import TableProDatabase +import TableProModels +@testable import TableProMobile + +/// Integration tests for the iOS MSSQL driver against a real SQL Server instance. +/// +/// All tests skip unless `MSSQL_TEST_HOST` is set in the environment. To run locally: +/// ``` +/// MSSQL_TEST_HOST=localhost MSSQL_TEST_USER=sa MSSQL_TEST_PASSWORD='YourStrong!Pass' \ +/// xcodebuild test -scheme TableProMobile -only-testing:TableProMobileTests/MSSQLDriverTests +/// ``` +final class MSSQLDriverTests: XCTestCase { + private var driver: MSSQLDriver? + + private static func loadTestConfig() -> [String: String]? { + let env = ProcessInfo.processInfo.environment + if env["MSSQL_TEST_HOST"] != nil { + return env + } + let fallbackPath = env["MSSQL_TEST_CONFIG_PATH"] ?? "/tmp/mssql-test.json" + guard let data = FileManager.default.contents(atPath: fallbackPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return nil } + return json + } + + override func setUp() async throws { + guard let config = Self.loadTestConfig() else { + throw XCTSkip("MSSQL_TEST_HOST not set and /tmp/mssql-test.json not found, skipping integration tests") + } + let mode: SSLConfiguration.SSLMode + switch config["MSSQL_TEST_SSL_MODE"] ?? "disable" { + case "require": mode = .require + case "verifyCa": mode = .verifyCa + case "verifyFull": mode = .verifyFull + default: mode = .disable + } + let connection = DatabaseConnection( + name: "test", + type: .mssql, + host: config["MSSQL_TEST_HOST"] ?? "localhost", + port: Int(config["MSSQL_TEST_PORT"] ?? "1433") ?? 1433, + username: config["MSSQL_TEST_USER"] ?? "sa", + database: config["MSSQL_TEST_DATABASE"] ?? "master", + additionalFields: ["mssqlSchema": "dbo"], + sslEnabled: mode != .disable, + sslConfiguration: SSLConfiguration(mode: mode) + ) + let password = config["MSSQL_TEST_PASSWORD"] ?? "" + driver = MSSQLDriver(connection: connection, password: password) + try await driver?.connect() + } + + override func tearDown() async throws { + try await driver?.disconnect() + driver = nil + } + + func testConnectAndPing() async throws { + let driver = try XCTUnwrap(driver) + let pong = try await driver.ping() + XCTAssertTrue(pong) + XCTAssertNotNil(driver.serverVersion) + } + + func testSimpleQuery() async throws { + let driver = try XCTUnwrap(driver) + let result = try await driver.execute(query: "SELECT 1 AS one, 'hello' AS greeting") + XCTAssertEqual(result.columns.map { $0.name }, ["one", "greeting"]) + XCTAssertEqual(result.rows.first?[0], "1") + XCTAssertEqual(result.rows.first?[1], "hello") + } + + func testFetchDatabasesIncludesMaster() async throws { + let driver = try XCTUnwrap(driver) + let names = try await driver.fetchDatabases() + XCTAssertTrue(names.contains("master")) + } + + func testFetchSchemasExcludesSystemSchemas() async throws { + let driver = try XCTUnwrap(driver) + let names = try await driver.fetchSchemas() + XCTAssertFalse(names.contains("sys")) + XCTAssertFalse(names.contains("information_schema")) + } + + func testFetchTablesReturnsResults() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tablepro_test_table', 'U') IS NULL + CREATE TABLE dbo.tablepro_test_table (id INT PRIMARY KEY IDENTITY(1,1), name NVARCHAR(100)) + """) + let tables = try await driver.fetchTables(schema: "dbo") + XCTAssertTrue(tables.contains { $0.name == "tablepro_test_table" && $0.type == .table }) + } + + func testFetchColumnsReturnsTypeMetadata() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tablepro_test_table', 'U') IS NULL + CREATE TABLE dbo.tablepro_test_table (id INT PRIMARY KEY IDENTITY(1,1), name NVARCHAR(100)) + """) + let columns = try await driver.fetchColumns(table: "tablepro_test_table", schema: "dbo") + XCTAssertEqual(columns.count, 2) + let id = columns.first { $0.name == "id" } + XCTAssertEqual(id?.isPrimaryKey, true) + XCTAssertEqual(id?.typeName, "int") + let name = columns.first { $0.name == "name" } + XCTAssertEqual(name?.typeName, "nvarchar(100)") + } + + func testExplicitTransactionRollback() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tablepro_tx_test', 'U') IS NULL + CREATE TABLE dbo.tablepro_tx_test (v INT) + """) + _ = try await driver.execute(query: "DELETE FROM dbo.tablepro_tx_test") + + try await driver.beginTransaction() + _ = try await driver.execute(query: "INSERT INTO dbo.tablepro_tx_test VALUES (42)") + try await driver.rollbackTransaction() + + let result = try await driver.execute(query: "SELECT COUNT(*) FROM dbo.tablepro_tx_test") + XCTAssertEqual(result.rows.first?.first, "0") + } + + func testSwitchDatabaseChangesActiveDatabase() async throws { + let driver = try XCTUnwrap(driver) + try await driver.switchDatabase(to: "tempdb") + let result = try await driver.execute(query: "SELECT DB_NAME()") + XCTAssertEqual(result.rows.first?.first, "tempdb") + } + + func testSortedPaginationEmitsOrderByAndFetch() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tablepro_pagination_test', 'U') IS NOT NULL + DROP TABLE dbo.tablepro_pagination_test; + CREATE TABLE dbo.tablepro_pagination_test (id INT PRIMARY KEY, label NVARCHAR(20)); + INSERT INTO dbo.tablepro_pagination_test VALUES + (1, N'a'), (2, N'b'), (3, N'c'), (4, N'd'), (5, N'e'); + """) + let result = try await driver.execute(query: """ + SELECT id, label FROM dbo.tablepro_pagination_test + ORDER BY id ASC + OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY + """) + XCTAssertEqual(result.rows.count, 2) + XCTAssertEqual(result.rows.first?.first, "3") + XCTAssertEqual(result.rows.last?.first, "4") + } + + func testCancellationStopsQuery() async throws { + let driver = try XCTUnwrap(driver) + let task = Task { [driver] in + try await driver.execute(query: "WAITFOR DELAY '00:00:05'; SELECT 1") + } + try await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + do { + _ = try await task.value + XCTFail("Cancelled task should have thrown") + } catch is CancellationError { + // expected + } catch { + // FreeTDS may surface as DatabaseError on race; both are acceptable signals that the + // query was interrupted rather than completing the 5-second WAITFOR. + XCTAssertNotNil(error) + } + } + + func testWrongPasswordFailsClearly() async throws { + try XCTSkipIf(Self.loadTestConfig() == nil, "no test config") + let connection = DatabaseConnection( + name: "wrong-pw", + type: .mssql, + host: "localhost", + port: 1433, + username: "sa", + database: "master", + additionalFields: ["mssqlSchema": "dbo"] + ) + let badDriver = MSSQLDriver(connection: connection, password: "definitely-wrong-pass-123") + do { + try await badDriver.connect() + XCTFail("Bad password should fail to connect") + } catch { + // any error is acceptable; verify it's not nil and surfaces a message + let desc = (error as? LocalizedError)?.errorDescription ?? "\(error)" + XCTAssertFalse(desc.isEmpty) + } + } + + func testUnreachableHostFails() async throws { + try XCTSkipIf(Self.loadTestConfig() == nil, "no test config") + let connection = DatabaseConnection( + name: "unreachable", + type: .mssql, + host: "192.0.2.1", + port: 1433, + username: "sa", + database: "master", + additionalFields: ["mssqlSchema": "dbo", "mssqlLoginTimeout": "5"] + ) + let badDriver = MSSQLDriver(connection: connection, password: "x") + do { + try await badDriver.connect() + XCTFail("Connecting to RFC5737 TEST-NET should fail") + } catch { + let desc = (error as? LocalizedError)?.errorDescription ?? "\(error)" + XCTAssertFalse(desc.isEmpty) + } + } + + // Note: FreeTDS db-lib does not expose per-connection cert validation. `verifyFull` maps to + // `require` (TLS on, but no CA chain check). Testing strict TLS rejection requires either + // building FreeTDS with custom OpenSSL callbacks or trusting the certificate via machine-wide + // freetds.conf, neither of which is per-connection. Out of scope for this driver. + + func testSortedWithFilterCombinesOrderAndWhere() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tp_filter_sort', 'U') IS NOT NULL DROP TABLE dbo.tp_filter_sort; + CREATE TABLE dbo.tp_filter_sort (id INT PRIMARY KEY, kind NVARCHAR(10), score INT); + INSERT INTO dbo.tp_filter_sort VALUES + (1, N'a', 10), (2, N'b', 5), (3, N'a', 20), (4, N'a', 15), (5, N'b', 1); + """) + let result = try await driver.execute(query: """ + SELECT id, kind, score FROM [dbo].[tp_filter_sort] + WHERE kind = N'a' + ORDER BY score DESC + OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY + """) + XCTAssertEqual(result.rows.count, 2) + XCTAssertEqual(result.rows.first?[0], "3") + XCTAssertEqual(result.rows.last?[0], "4") + } + + func testSelectTopOneCellLookup() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tp_lookup', 'U') IS NOT NULL DROP TABLE dbo.tp_lookup; + CREATE TABLE dbo.tp_lookup (id INT PRIMARY KEY, payload NVARCHAR(MAX)); + INSERT INTO dbo.tp_lookup VALUES (7, N'hello'); + """) + let result = try await driver.execute(query: "SELECT TOP 1 [payload] FROM [dbo].[tp_lookup] WHERE [id] = 7") + XCTAssertEqual(result.rows.first?.first, "hello") + } + + func testMultiColumnForeignKey() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tp_fk_child', 'U') IS NOT NULL DROP TABLE dbo.tp_fk_child; + IF OBJECT_ID('dbo.tp_fk_parent', 'U') IS NOT NULL DROP TABLE dbo.tp_fk_parent; + CREATE TABLE dbo.tp_fk_parent ( + tenant_id INT NOT NULL, + external_id INT NOT NULL, + CONSTRAINT pk_tp_fk_parent PRIMARY KEY (tenant_id, external_id) + ); + CREATE TABLE dbo.tp_fk_child ( + id INT PRIMARY KEY, + tenant_id INT NOT NULL, + external_id INT NOT NULL, + CONSTRAINT fk_tp_fk_child_parent FOREIGN KEY (tenant_id, external_id) + REFERENCES dbo.tp_fk_parent (tenant_id, external_id) + ); + """) + let fks = try await driver.fetchForeignKeys(table: "tp_fk_child", schema: "dbo") + let matched = fks.filter { $0.name == "fk_tp_fk_child_parent" } + XCTAssertEqual(matched.count, 2, "composite FK should produce two rows") + let cols = Set(matched.map { $0.column }) + XCTAssertEqual(cols, Set(["tenant_id", "external_id"])) + XCTAssertTrue(matched.allSatisfy { $0.referencedTable == "tp_fk_parent" }) + } +} diff --git a/scripts/build-freetds.sh b/scripts/build-freetds.sh index 7aee20573..7912be093 100755 --- a/scripts/build-freetds.sh +++ b/scripts/build-freetds.sh @@ -1,79 +1,185 @@ #!/usr/bin/env bash -# Build FreeTDS static libraries for arm64 and x86_64, then lipo-merge to universal. -# Outputs to Libs/ and copies headers to TablePro/Core/Database/CFreeTDS/include/ +# Build FreeTDS static libraries for macOS (arm64 + x86_64) and iOS (arm64 device + arm64 simulator), +# then package as a unified xcframework with bundled Swift module map. +# +# Output: Libs/ios/FreeTDS.xcframework, consumed by both the macOS MSSQL plugin and the iOS app +# via the CFreeTDS Swift module. +# +# Prerequisites: +# brew install autoconf automake libtool openssl@3 +# Xcode 15+ (for xcrun, xcodebuild -create-xcframework) +# Libs/ios/OpenSSL-SSL.xcframework + OpenSSL-Crypto.xcframework already downloaded +# (run scripts/download-libs.sh first if needed) # # Usage: bash scripts/build-freetds.sh -# Prerequisites: brew install autoconf automake libtool -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" LIBS_DIR="$PROJECT_DIR/Libs" +IOS_LIBS_DIR="$LIBS_DIR/ios" FREETDS_VERSION="1.4.22" FREETDS_SHA256="6acb9086350425f5178e544bbe2d54a001097e8e20277a2b766ad0799a2e7d87" FREETDS_URL="https://www.freetds.org/files/stable/freetds-${FREETDS_VERSION}.tar.gz" BUILD_DIR="/tmp/freetds-build" -INCLUDE_DST="$PROJECT_DIR/TablePro/Core/Database/CFreeTDS/include" +SOURCE_DIR="$BUILD_DIR/freetds-${FREETDS_VERSION}" +MACOS_DEPLOYMENT_TARGET="14.0" +IOS_DEPLOYMENT_TARGET="17.0" + +MACOS_OPENSSL_PREFIX="$(brew --prefix openssl@3)" +IOS_OPENSSL_SSL_XCFW="$IOS_LIBS_DIR/OpenSSL-SSL.xcframework" +IOS_OPENSSL_CRYPTO_XCFW="$IOS_LIBS_DIR/OpenSSL-Crypto.xcframework" + +if [ ! -d "$IOS_OPENSSL_SSL_XCFW" ] || [ ! -d "$IOS_OPENSSL_CRYPTO_XCFW" ]; then + echo "ERROR: iOS OpenSSL xcframeworks not found in $IOS_LIBS_DIR" + echo "Run scripts/download-libs.sh first." + exit 1 +fi -mkdir -p "$BUILD_DIR" "$LIBS_DIR" "$INCLUDE_DST" +mkdir -p "$BUILD_DIR" "$LIBS_DIR" "$IOS_LIBS_DIR" -echo "Downloading FreeTDS ${FREETDS_VERSION}..." -curl -fSL "$FREETDS_URL" -o "$BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" +echo "==> Downloading FreeTDS ${FREETDS_VERSION}..." +if [ ! -f "$BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" ]; then + curl -fSL "$FREETDS_URL" -o "$BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" +fi echo "$FREETDS_SHA256 $BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" | shasum -a 256 -c - + +rm -rf "$SOURCE_DIR" tar xz -C "$BUILD_DIR" -f "$BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" -SOURCE_DIR="$BUILD_DIR/freetds-${FREETDS_VERSION}" -build_arch() { - local ARCH="$1" - local PREFIX="/tmp/freetds-${ARCH}" - local HOST_TRIPLE - if [ "$ARCH" = "arm64" ]; then - HOST_TRIPLE="aarch64-apple-darwin" - else - HOST_TRIPLE="x86_64-apple-darwin" - fi - - echo "Building FreeTDS for ${ARCH}..." +build_slice() { + local SLICE_LABEL="$1" + local SDK="$2" + local ARCH="$3" + local HOST_TRIPLE="$4" + local VERSION_FLAG="$5" + local OPENSSL_PREFIX="$6" + + local PREFIX="/tmp/freetds-${SLICE_LABEL}" + local SDKPATH + SDKPATH="$(xcrun --sdk "$SDK" --show-sdk-path)" + local CC_BIN + CC_BIN="$(xcrun -sdk "$SDK" -find clang)" + + echo "==> Building FreeTDS for ${SLICE_LABEL} (${ARCH}, ${SDK})..." + rm -rf "$PREFIX" pushd "$SOURCE_DIR" > /dev/null make distclean 2>/dev/null || true - ./configure \ - --prefix="$PREFIX" \ - --host="$HOST_TRIPLE" \ - --disable-shared \ - --enable-static \ - --disable-odbc \ - --with-tdsver=7.4 \ - CFLAGS="-arch ${ARCH} -mmacosx-version-min=14.0" \ - LDFLAGS="-arch ${ARCH}" - make -j"$(sysctl -n hw.logicalcpu)" - make install + rm -f config.cache + + # Pre-seed AC_RUN_IFELSE results via env vars (correct for all 64-bit Apple platforms). + # Avoids --cache-file which autoconf rejects when host/CFLAGS change between slices. + env \ + ac_cv_func_malloc_0_nonnull=yes \ + ac_cv_func_realloc_0_nonnull=yes \ + ac_cv_func_memcmp_working=yes \ + ac_cv_func_iconv_open=yes \ + ac_cv_sizeof_int=4 \ + ac_cv_sizeof_long=8 \ + ac_cv_sizeof_long_long=8 \ + ac_cv_sizeof_void_p=8 \ + ac_cv_c_bigendian=no \ + ./configure \ + --prefix="$PREFIX" \ + --host="$HOST_TRIPLE" \ + --disable-shared \ + --enable-static \ + --disable-odbc \ + --disable-libiconv \ + --with-tdsver=7.4 \ + --with-openssl="$OPENSSL_PREFIX" \ + CC="$CC_BIN" \ + CFLAGS="-arch ${ARCH} -isysroot ${SDKPATH} ${VERSION_FLAG} -I${OPENSSL_PREFIX}/include" \ + LDFLAGS="-arch ${ARCH} -isysroot ${SDKPATH} -L${OPENSSL_PREFIX}/lib" + + # Build only the libraries we need (skip src/apps which require readline + native exec). + # SUBDIRS order matches src/Makefile: utils → replacements → tds → dblib. + make -j"$(sysctl -n hw.logicalcpu)" -C include + make -j"$(sysctl -n hw.logicalcpu)" -C src/utils + make -j"$(sysctl -n hw.logicalcpu)" -C src/replacements + make -j"$(sysctl -n hw.logicalcpu)" -C src/tds + make -j"$(sysctl -n hw.logicalcpu)" -C src/dblib + make -C src/dblib install popd > /dev/null - cp "$PREFIX/lib/libsybdb.a" "$LIBS_DIR/libsybdb_${ARCH}.a" - echo "Built libsybdb_${ARCH}.a" + cp "$PREFIX/lib/libsybdb.a" "$LIBS_DIR/libsybdb_${SLICE_LABEL}.a" + echo " built libsybdb_${SLICE_LABEL}.a" } -build_arch "arm64" -build_arch "x86_64" +# macOS slices use per-arch static OpenSSL from Libs/ to avoid brew's arm64-only dylib at the +# linker step. Brew supplies headers (arch-agnostic); the .a files come from Libs/. +MACOS_OPENSSL_ARM64="$(mktemp -d)/openssl-macos-arm64" +MACOS_OPENSSL_X86_64="$(mktemp -d)/openssl-macos-x86_64" +mkdir -p "$MACOS_OPENSSL_ARM64/include/openssl" "$MACOS_OPENSSL_ARM64/lib" +mkdir -p "$MACOS_OPENSSL_X86_64/include/openssl" "$MACOS_OPENSSL_X86_64/lib" +cp -R "$MACOS_OPENSSL_PREFIX/include/openssl/." "$MACOS_OPENSSL_ARM64/include/openssl/" +cp -R "$MACOS_OPENSSL_PREFIX/include/openssl/." "$MACOS_OPENSSL_X86_64/include/openssl/" +cp "$LIBS_DIR/libssl_arm64.a" "$MACOS_OPENSSL_ARM64/lib/libssl.a" +cp "$LIBS_DIR/libcrypto_arm64.a" "$MACOS_OPENSSL_ARM64/lib/libcrypto.a" +cp "$LIBS_DIR/libssl_x86_64.a" "$MACOS_OPENSSL_X86_64/lib/libssl.a" +cp "$LIBS_DIR/libcrypto_x86_64.a" "$MACOS_OPENSSL_X86_64/lib/libcrypto.a" + +build_slice "macos-arm64" "macosx" "arm64" "aarch64-apple-darwin" "-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}" "$MACOS_OPENSSL_ARM64" +build_slice "macos-x86_64" "macosx" "x86_64" "x86_64-apple-darwin" "-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}" "$MACOS_OPENSSL_X86_64" + +# iOS slices link OpenSSL statically from the existing xcframeworks; reconstruct a unix-style prefix +# for FreeTDS's --with-openssl which expects include/ and lib/ siblings. +IOS_OPENSSL_DEVICE="$(mktemp -d)/openssl-ios-arm64" +IOS_OPENSSL_SIM="$(mktemp -d)/openssl-ios-arm64-simulator" +mkdir -p "$IOS_OPENSSL_DEVICE/include" "$IOS_OPENSSL_DEVICE/lib" +mkdir -p "$IOS_OPENSSL_SIM/include" "$IOS_OPENSSL_SIM/lib" +cp -R "$IOS_OPENSSL_SSL_XCFW/ios-arm64/Headers/." "$IOS_OPENSSL_DEVICE/include/" +cp "$IOS_OPENSSL_SSL_XCFW/ios-arm64/libssl.a" "$IOS_OPENSSL_DEVICE/lib/" +cp "$IOS_OPENSSL_CRYPTO_XCFW/ios-arm64/libcrypto.a" "$IOS_OPENSSL_DEVICE/lib/" +cp -R "$IOS_OPENSSL_SSL_XCFW/ios-arm64-simulator/Headers/." "$IOS_OPENSSL_SIM/include/" +cp "$IOS_OPENSSL_SSL_XCFW/ios-arm64-simulator/libssl.a" "$IOS_OPENSSL_SIM/lib/" +cp "$IOS_OPENSSL_CRYPTO_XCFW/ios-arm64-simulator/libcrypto.a" "$IOS_OPENSSL_SIM/lib/" -echo "Creating universal binary..." +build_slice "ios-arm64" "iphoneos" "arm64" "aarch64-apple-darwin" "-mios-version-min=${IOS_DEPLOYMENT_TARGET}" "$IOS_OPENSSL_DEVICE" +build_slice "ios-arm64-simulator" "iphonesimulator" "arm64" "aarch64-apple-darwin" "-mios-simulator-version-min=${IOS_DEPLOYMENT_TARGET}" "$IOS_OPENSSL_SIM" + +echo "==> Creating macOS universal slice..." lipo -create \ - "$LIBS_DIR/libsybdb_arm64.a" \ - "$LIBS_DIR/libsybdb_x86_64.a" \ - -output "$LIBS_DIR/libsybdb_universal.a" + "$LIBS_DIR/libsybdb_macos-arm64.a" \ + "$LIBS_DIR/libsybdb_macos-x86_64.a" \ + -output "$LIBS_DIR/libsybdb_macos_universal.a" + +HEADERS_STAGE="$BUILD_DIR/headers-stage" +rm -rf "$HEADERS_STAGE" +mkdir -p "$HEADERS_STAGE" +cp "$SOURCE_DIR/include/sybdb.h" "$HEADERS_STAGE/" +cp "$SOURCE_DIR/include/sybfront.h" "$HEADERS_STAGE/" -cp "$LIBS_DIR/libsybdb_universal.a" "$LIBS_DIR/libsybdb.a" +# Do NOT copy raw FreeTDS headers into Plugins/MSSQLDriverPlugin/CFreeTDS/include/. Those are +# hand-curated Swift-compatible stubs. Upstream sybdb.h transitively requires generated headers +# (tds_sysdep_public.h, etc.) that we don't ship. The xcframework's bundled headers are also stubs +# for consumers; the real symbols are exported by libsybdb.a at link time. -echo "Copying headers..." -cp /tmp/freetds-arm64/include/sybdb.h "$INCLUDE_DST/sybdb.h" -cp /tmp/freetds-arm64/include/sybfront.h "$INCLUDE_DST/sybfront.h" +echo "==> Assembling FreeTDS.xcframework..." +XCFRAMEWORK_OUT="$IOS_LIBS_DIR/FreeTDS.xcframework" +rm -rf "$XCFRAMEWORK_OUT" +xcodebuild -create-xcframework \ + -library "$LIBS_DIR/libsybdb_macos_universal.a" -headers "$HEADERS_STAGE" \ + -library "$LIBS_DIR/libsybdb_ios-arm64.a" -headers "$HEADERS_STAGE" \ + -library "$LIBS_DIR/libsybdb_ios-arm64-simulator.a" -headers "$HEADERS_STAGE" \ + -output "$XCFRAMEWORK_OUT" -echo "FreeTDS build complete!" -echo "Libraries in: $LIBS_DIR" -echo "Headers in: $INCLUDE_DST" +echo "==> Cleaning intermediate per-slice archives..." +rm -f \ + "$LIBS_DIR/libsybdb_macos-arm64.a" \ + "$LIBS_DIR/libsybdb_macos-x86_64.a" \ + "$LIBS_DIR/libsybdb_macos_universal.a" \ + "$LIBS_DIR/libsybdb_ios-arm64.a" \ + "$LIBS_DIR/libsybdb_ios-arm64-simulator.a" + +echo "" +echo "FreeTDS.xcframework built at: $XCFRAMEWORK_OUT" +echo "Slices:" +ls -1 "$XCFRAMEWORK_OUT" | grep -v Info.plist | sed 's/^/ - /' echo "" echo "NEXT STEPS:" -echo " 1. Add the CFreeTDS module to Xcode project" -echo " 2. Add libsybdb.a to Link Binary With Libraries" -echo " 3. Add CFreeTDS/include/ to Header Search Paths" +echo " 1. Inspect: xcodebuild -checkFirstLaunchStatus; file ${XCFRAMEWORK_OUT}/*/libsybdb.a" +echo " 2. Re-pack iOS libs archive and upload to libs-v1 release:" +echo " tar czf /tmp/tablepro-libs-ios-v1.tar.gz -C ${IOS_LIBS_DIR} ." +echo " gh release upload libs-v1 /tmp/tablepro-libs-ios-v1.tar.gz --clobber --repo TableProApp/TablePro"