diff --git a/CHANGELOG.md b/CHANGELOG.md index e5aebfd82..c1d633510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- SQL Server: connections work when the login can only reach its own database, such as an Azure SQL contained user. The database is now sent during login. Previously it was switched afterward, which the server rejected with a "Login failed" error. - Custom Copy and Cut shortcuts now take effect in the SQL editor. - The Delete shortcut in the data grid now follows a custom binding. - Find Next (Cmd+G) and Find Previous (Cmd+Shift+G) now work in the editor. diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index d989f80fe..183d21c6a 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -148,13 +148,16 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { } 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)) + for parameter in MSSQLLoginParameters.build( + user: options.user, + password: options.password, + applicationName: options.applicationName, + encryptionFlag: options.encryptionFlag, + database: options.database + ) { + _ = dbsetlname(login, parameter.value, parameter.field.dbsetName) + } _ = 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 @@ -172,13 +175,6 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { 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 @@ -551,3 +547,17 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { return nil } } + +private extension MSSQLLoginField { + var dbsetName: Int32 { + switch self { + case .user: return Int32(DBSETUSER) + case .password: return Int32(DBSETPWD) + case .application: return Int32(DBSETAPP) + case .nationalLanguage: return Int32(DBSETNATLANG) + case .charset: return Int32(DBSETCHARSET) + case .encryption: return Int32(DBSETENCRYPT) + case .database: return Int32(DBSETDBNAME) + } + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift b/Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift new file mode 100644 index 000000000..7fea2e397 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift @@ -0,0 +1,42 @@ +import Foundation + +enum MSSQLLoginField: Equatable { + case user + case password + case application + case nationalLanguage + case charset + case encryption + case database +} + +struct MSSQLLoginParameter: Equatable { + let field: MSSQLLoginField + let value: String +} + +enum MSSQLLoginParameters { + static let nationalLanguage = "us_english" + static let charset = "UTF-8" + + static func build( + user: String, + password: String, + applicationName: String, + encryptionFlag: String, + database: String + ) -> [MSSQLLoginParameter] { + var parameters = [ + MSSQLLoginParameter(field: .user, value: user), + MSSQLLoginParameter(field: .password, value: password), + MSSQLLoginParameter(field: .application, value: applicationName), + MSSQLLoginParameter(field: .nationalLanguage, value: nationalLanguage), + MSSQLLoginParameter(field: .charset, value: charset), + MSSQLLoginParameter(field: .encryption, value: encryptionFlag) + ] + if !database.isEmpty { + parameters.append(MSSQLLoginParameter(field: .database, value: database)) + } + return parameters + } +} diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index 052bb3376..2954f68cd 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 5AD1F1B82FB4456900296783 /* FreeTDS.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AD1F1B72FB4456900296783 /* FreeTDS.xcframework */; }; 5AD0CCDB2F0000000000F002 /* DuckDB.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AD0CCDB2F0000000000F001 /* DuckDB.xcframework */; }; 5AD1F2002FB5500000000002 /* FreeTDSConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AD1F2002FB5500000000001 /* FreeTDSConnection.swift */; }; + 5AD1F2012FB5500000000002 /* MSSQLLoginParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AD1F2012FB5500000000001 /* MSSQLLoginParameters.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -532,6 +533,7 @@ 5AD1F1B72FB4456900296783 /* FreeTDS.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = FreeTDS.xcframework; path = ../Libs/ios/FreeTDS.xcframework; sourceTree = ""; }; 5AD0CCDB2F0000000000F001 /* DuckDB.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = DuckDB.xcframework; path = ../Libs/ios/DuckDB.xcframework; sourceTree = ""; }; 5AD1F2002FB5500000000001 /* FreeTDSConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FreeTDSConnection.swift; path = ../Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift; sourceTree = ""; }; + 5AD1F2012FB5500000000001 /* MSSQLLoginParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MSSQLLoginParameters.swift; path = ../Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -1681,6 +1683,7 @@ isa = PBXGroup; children = ( 5AD1F2002FB5500000000001 /* FreeTDSConnection.swift */, + 5AD1F2012FB5500000000001 /* MSSQLLoginParameters.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -1848,6 +1851,7 @@ buildActionMask = 2147483647; files = ( 5AD1F2002FB5500000000002 /* FreeTDSConnection.swift in Sources */, + 5AD1F2012FB5500000000002 /* MSSQLLoginParameters.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TableProTests/PluginTestSources/MSSQLLoginParameters.swift b/TableProTests/PluginTestSources/MSSQLLoginParameters.swift new file mode 120000 index 000000000..187c89337 --- /dev/null +++ b/TableProTests/PluginTestSources/MSSQLLoginParameters.swift @@ -0,0 +1 @@ +../../Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift \ No newline at end of file diff --git a/TableProTests/Plugins/MSSQLLoginParametersTests.swift b/TableProTests/Plugins/MSSQLLoginParametersTests.swift new file mode 100644 index 000000000..8489d6826 --- /dev/null +++ b/TableProTests/Plugins/MSSQLLoginParametersTests.swift @@ -0,0 +1,46 @@ +// +// MSSQLLoginParametersTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("MSSQLLoginParameters.build") +struct MSSQLLoginParametersTests { + private func build(database: String) -> [MSSQLLoginParameter] { + MSSQLLoginParameters.build( + user: "carrier", + password: "secret", + applicationName: "TablePro", + encryptionFlag: "require", + database: database + ) + } + + @Test("includes the database in the login packet when set") + func includesDatabaseWhenSet() { + let parameters = build(database: "tmsdevdb1") + #expect(parameters.contains(MSSQLLoginParameter(field: .database, value: "tmsdevdb1"))) + } + + @Test("omits the database when blank") + func omitsDatabaseWhenBlank() { + let fields = build(database: "").map(\.field) + #expect(!fields.contains(.database)) + } + + @Test("carries the credentials and encryption flag") + func carriesCredentials() { + let parameters = build(database: "tmsdevdb1") + #expect(parameters.contains(MSSQLLoginParameter(field: .user, value: "carrier"))) + #expect(parameters.contains(MSSQLLoginParameter(field: .password, value: "secret"))) + #expect(parameters.contains(MSSQLLoginParameter(field: .encryption, value: "require"))) + } + + @Test("sets us_english language to settle the initial login state") + func setsNationalLanguage() { + let parameters = build(database: "tmsdevdb1") + #expect(parameters.contains(MSSQLLoginParameter(field: .nationalLanguage, value: "us_english"))) + } +} diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index 367b9e183..4c108e475 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -22,7 +22,7 @@ Run `scripts/build-freetds.sh` first. Click **New Connection**, select **SQL Ser | **Host** | `localhost` | | | **Port** | `1433` | | | **Username** | `sa` | SQL Server Authentication only (no Windows Auth) | -| **Database** | - | Optional. Leave empty to browse all databases. Switch with **Cmd+K** | +| **Database** | - | Optional. Leave empty to browse all databases. Switch with **Cmd+K**. Required for logins scoped to a single database, such as Azure SQL contained users | ## Example Configurations @@ -96,7 +96,7 @@ For Azure SQL Database, pick **Required** or stricter. For SQL Server 2017+ on a **Connection refused**: Enable TCP/IP in SQL Server Configuration Manager, verify SQL Server service running, check firewall port 1433, ensure Docker started. -**Login failed**: Verify credentials, check mixed-mode authentication enabled: `SELECT SERVERPROPERTY('IsIntegratedSecurityOnly');` Set to 2 for mixed mode. +**Login failed**: Verify credentials, check mixed-mode authentication enabled: `SELECT SERVERPROPERTY('IsIntegratedSecurityOnly');` Set to 2 for mixed mode. If the login only has access to one database (an Azure SQL contained user), set the **Database** field. TablePro sends it during login; without it the server authenticates against `master` and rejects the login. **FreeTDS not found**: Run `scripts/build-freetds.sh` to compile and place library.