Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- The username is now optional. Leaving it empty, or importing a connection URL that has no user in it, no longer fills in `root`. An empty username lets the database use its own default, the same as `psql` and `mysql` do: your Mac login name on MySQL/MariaDB and PostgreSQL, and `default` on ClickHouse. The `~/.pgpass` lookup now matches on that same user.
- A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background.
- Pinned results are no longer discarded by **Clear Results**, and a tab holding one is no longer reused to browse a different table. (#1855)
- Quitting now warns about unsaved changes in any tab, not just the visible one. Unsaved edits to a `.sql` file, table structure changes, and pending truncates and deletes were all missed before. (#1854)
Expand Down
20 changes: 20 additions & 0 deletions Plugins/ClickHouseDriverPlugin/ClickHouseCredentials.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// ClickHouseCredentials.swift
// ClickHouseDriverPlugin
//

import Foundation

internal enum ClickHouseCredentials {
internal static let defaultUsername = "default"

internal static func effectiveUsername(_ username: String) -> String {
username.isEmpty ? defaultUsername : username
}

internal static func basicAuthorizationHeader(username: String, password: String) -> String? {
let credentials = "\(effectiveUsername(username)):\(password)"
guard let data = credentials.data(using: .utf8) else { return nil }
return "Basic \(data.base64EncodedString())"
}
}
8 changes: 5 additions & 3 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -615,9 +615,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
var request = URLRequest(url: url)
request.httpMethod = "POST"

let credentials = "\(config.username):\(config.password)"
if let credData = credentials.data(using: .utf8) {
request.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization")
if let authorization = ClickHouseCredentials.basicAuthorizationHeader(
username: config.username,
password: config.password
) {
request.setValue(authorization, forHTTPHeaderField: "Authorization")
}

request.httpBody = (query + " FORMAT JSONEachRow").data(using: .utf8)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,11 @@ extension ClickHousePluginDriver {
var request = URLRequest(url: url)
request.httpMethod = "POST"

let credentials = "\(config.username):\(config.password)"
if let credData = credentials.data(using: .utf8) {
request.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization")
if let authorization = ClickHouseCredentials.basicAuthorizationHeader(
username: config.username,
password: config.password
) {
request.setValue(authorization, forHTTPHeaderField: "Authorization")
}

let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
1 change: 1 addition & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@
5A863000E00000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "TableProTests" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ClickHouseCredentials.swift,
ClickHouseTableOperations.swift,
);
target = 5ABCC5A62F43856700EAF3FC /* TableProTests */;
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Utilities/Connection/PgpassReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ enum PgpassReader {
return posixPerms == 0o600
}

/// libpq resolves an unset user to the operating system login name before matching ~/.pgpass,
/// so a blank username field must resolve the same way here.
static func effectiveUsername(_ username: String) -> String {
username.isEmpty ? NSUserName() : username
}

/// Resolve a password from ~/.pgpass per PostgreSQL spec.
/// Returns the password from the first matching entry, or nil if no match.
/// Format: hostname:port:database:username:password
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ struct DatabaseConnection: Identifiable, Hashable {
host: String = "localhost",
port: Int = 3_306,
database: String = "",
username: String = "root",
username: String = "",
type: DatabaseType = .mysql,
sshConfig: SSHConfiguration = SSHConfiguration(),
sslConfig: SSLConfiguration = SSLConfiguration(),
Expand Down Expand Up @@ -569,7 +569,7 @@ extension DatabaseConnection: Codable {
host = try container.decodeIfPresent(String.self, forKey: .host) ?? "localhost"
port = try container.decodeIfPresent(Int.self, forKey: .port) ?? 3_306
database = try container.decodeIfPresent(String.self, forKey: .database) ?? ""
username = try container.decodeIfPresent(String.self, forKey: .username) ?? "root"
username = try container.decodeIfPresent(String.self, forKey: .username) ?? ""
type = try container.decodeIfPresent(DatabaseType.self, forKey: .type) ?? .mysql
sshConfig = try container.decodeIfPresent(SSHConfiguration.self, forKey: .sshConfig) ?? SSHConfiguration()
sslConfig = try container.decodeIfPresent(SSLConfiguration.self, forKey: .sslConfig) ?? SSLConfiguration()
Expand Down
20 changes: 6 additions & 14 deletions TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,9 @@ final class ConnectionFormCoordinator {
let sshConfig = ssh.state.buildSSHConfig()
let sslConfig = ssl.buildConfig()

var finalHost = network.host.trimmingCharacters(in: .whitespaces).isEmpty
? "localhost" : network.host
var finalPort = Int(network.port) ?? network.type.defaultPort
let trimmedUsername = auth.username.trimmingCharacters(in: .whitespaces)
let finalUsername =
trimmedUsername.isEmpty && services.pluginManager.requiresAuthentication(for: network.type)
? "root" : trimmedUsername
var finalHost = network.resolvedHost
var finalPort = network.resolvedPort
let finalUsername = auth.resolvedUsername

let finalId = connectionId ?? UUID()

Expand Down Expand Up @@ -427,13 +423,9 @@ final class ConnectionFormCoordinator {
let sshConfig = ssh.state.buildSSHConfig()
let sslConfig = ssl.buildConfig()

var testHost = network.host.trimmingCharacters(in: .whitespaces).isEmpty
? "localhost" : network.host
var testPort = Int(network.port) ?? network.type.defaultPort
let trimmedUsername = auth.username.trimmingCharacters(in: .whitespaces)
let finalUsername =
trimmedUsername.isEmpty && services.pluginManager.requiresAuthentication(for: network.type)
? "root" : trimmedUsername
var testHost = network.resolvedHost
var testPort = network.resolvedPort
let finalUsername = auth.resolvedUsername

var finalAdditionalFields: [String: String] = [:]
network.write(into: &finalAdditionalFields)
Expand Down
3 changes: 1 addition & 2 deletions TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,7 @@ struct GeneralPaneView: View {
if connectionMode == .network {
TextField(
String(localized: "Username"),
text: $coordinator.auth.username,
prompt: Text("root")
text: $coordinator.auth.username
)
}
if !coordinator.auth.hidesPassword {
Expand Down
15 changes: 10 additions & 5 deletions TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ final class AuthPaneViewModel {
.filter { $0.section == .authentication }
}

var resolvedUsername: String {
username.trimmingCharacters(in: .whitespaces)
}

var hidesBuiltInPassword: Bool {
guard let type = coordinator?.value?.network.type else { return false }
return PluginMetadataRegistry.shared.snapshot(forTypeId: type.pluginTypeId)?
Expand Down Expand Up @@ -138,10 +142,11 @@ final class AuthPaneViewModel {
pgpassStatus = .notChecked
return
}
let host = coordinator.network.host.isEmpty ? "localhost" : coordinator.network.host
let port = Int(coordinator.network.port) ?? coordinator.network.type.defaultPort
let database = coordinator.network.database
let username = self.username.isEmpty ? "root" : self.username
pgpassStatus = PgpassStatus.check(host: host, port: port, database: database, username: username)
pgpassStatus = PgpassStatus.check(
host: coordinator.network.resolvedHost,
port: coordinator.network.resolvedPort,
database: coordinator.network.database,
username: PgpassReader.effectiveUsername(resolvedUsername)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the effective pgpass user when resolving passwords

This makes the form check ~/.pgpass with the OS username when the username field is blank, but the actual connection path still calls PgpassReader.resolve(... username: connection.username) in TablePro/Core/Database/DatabaseDriver.swift:547-552. For PostgreSQL connections using ~/.pgpass through an SSH/Cloudflare/Cloud SQL tunnel, the app must resolve the password itself against the original host; with a blank saved username it now reports a match in the form, then looks up an empty username during connect and passes no password, so the connection can fail despite the green status.

Useful? React with 👍 / 👎.

)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ final class NetworkPaneViewModel {
return port == 0 ? "" : String(port)
}

var resolvedHost: String {
host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host
}

var resolvedPort: Int {
Int(port) ?? type.defaultPort
}

var supportsDatabaseField: Bool {
let mode = connectionMode
return mode == .fileBased
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// ConnectionURLImportUsernameTests.swift
// TableProTests
//

import Foundation
import TableProPluginKit
import Testing

@testable import TablePro

@Suite("Connection URL Import Username")
@MainActor
struct ConnectionURLImportUsernameTests {
private func parse(_ urlString: String) throws -> ParsedConnectionURL {
guard case .success(let parsed) = ConnectionURLParser.parse(urlString) else {
throw ConnectionURLParseError.invalidURL
}
return parsed
}

@Test("Importing a MySQL URL with no user info keeps the username empty")
func mysqlURLWithoutUserKeepsUsernameEmpty() throws {
let parsed = try parse("mysql://localhost:3306/shop")
let connection = TransientConnectionFactory.build(from: parsed)
#expect(connection.username.isEmpty)
}

@Test("Importing a PostgreSQL URL with no user info keeps the username empty")
func postgresURLWithoutUserKeepsUsernameEmpty() throws {
let parsed = try parse("postgresql://db.example.com:5432/analytics")
let connection = TransientConnectionFactory.build(from: parsed)
#expect(connection.username.isEmpty)
}

@Test("A URL with user info still imports that username")
func urlWithUserInfoKeepsUsername() throws {
let parsed = try parse("mysql://admin:secret@localhost:3306/shop")
let connection = TransientConnectionFactory.build(from: parsed)
#expect(connection.username == "admin")
}

@Test("An imported connection with no username exports a URL with no user info")
func emptyUsernameRoundTripsThroughFormatter() throws {
let parsed = try parse("mysql://localhost:3306/shop")
let connection = TransientConnectionFactory.build(from: parsed)
let url = ConnectionURLFormatter.format(connection, password: "", sshPassword: nil)
#expect(!url.contains("@"))
#expect(url.contains("localhost"))
}
}
22 changes: 22 additions & 0 deletions TableProTests/Core/Utilities/PgpassReaderUsernameTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// PgpassReaderUsernameTests.swift
// TableProTests
//

import Foundation
import Testing

@testable import TablePro

@Suite("Pgpass Effective Username")
struct PgpassReaderUsernameTests {
@Test("A blank username matches ~/.pgpass as the operating system user")
func blankUsernameResolvesToOSUser() {
#expect(PgpassReader.effectiveUsername("") == NSUserName())
}

@Test("An explicit username is matched as given")
func explicitUsernameIsPreserved() {
#expect(PgpassReader.effectiveUsername("analytics") == "analytics")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// DatabaseConnectionUsernameTests.swift
// TableProTests
//

import Foundation
import Testing

@testable import TablePro

@Suite("Database Connection Username")
struct DatabaseConnectionUsernameTests {
@Test("Username defaults to empty, never a fabricated account name")
func usernameDefaultsToEmpty() {
let connection = DatabaseConnection(name: "Local")
#expect(connection.username.isEmpty)
}

@Test("Decoding a connection with no username key yields an empty username")
func decodingWithoutUsernameKeyYieldsEmpty() throws {
let json = """
{
"id": "3F2504E0-4F89-11D3-9A0C-0305E82C3301",
"name": "No User",
"host": "localhost",
"port": 3306,
"type": "MySQL"
}
"""
let data = try #require(json.data(using: .utf8))
let connection = try JSONDecoder().decode(DatabaseConnection.self, from: data)
#expect(connection.username.isEmpty)
}

@Test("Decoding preserves an explicit username")
func decodingPreservesExplicitUsername() throws {
let json = """
{
"id": "3F2504E0-4F89-11D3-9A0C-0305E82C3302",
"name": "With User",
"host": "localhost",
"port": 3306,
"username": "admin",
"type": "MySQL"
}
"""
let data = try #require(json.data(using: .utf8))
let connection = try JSONDecoder().decode(DatabaseConnection.self, from: data)
#expect(connection.username == "admin")
}

@Test("An empty username round-trips through encoding")
func emptyUsernameRoundTrips() throws {
let connection = DatabaseConnection(name: "Local", username: "")
let data = try JSONEncoder().encode(connection)
let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data)
#expect(decoded.username.isEmpty)
}
}
36 changes: 36 additions & 0 deletions TableProTests/Plugins/ClickHouseCredentialsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// ClickHouseCredentialsTests.swift
// TableProTests
//

import Foundation
import Testing

@Suite("ClickHouse Credentials")
struct ClickHouseCredentialsTests {
@Test("A blank username resolves to the ClickHouse default user")
func blankUsernameResolvesToDefaultUser() {
#expect(ClickHouseCredentials.effectiveUsername("") == "default")
}

@Test("An explicit username is used as given")
func explicitUsernameIsPreserved() {
#expect(ClickHouseCredentials.effectiveUsername("analytics") == "analytics")
}

@Test("Basic authorization for a blank username authenticates as default")
func basicAuthorizationForBlankUsername() throws {
let header = try #require(ClickHouseCredentials.basicAuthorizationHeader(username: "", password: "secret"))
let encoded = header.replacingOccurrences(of: "Basic ", with: "")
let data = try #require(Data(base64Encoded: encoded))
#expect(String(data: data, encoding: .utf8) == "default:secret")
}

@Test("Basic authorization keeps an explicit username")
func basicAuthorizationForExplicitUsername() throws {
let header = try #require(ClickHouseCredentials.basicAuthorizationHeader(username: "analytics", password: ""))
let encoded = header.replacingOccurrences(of: "Basic ", with: "")
let data = try #require(Data(base64Encoded: encoded))
#expect(String(data: data, encoding: .utf8) == "analytics:")
}
}
2 changes: 1 addition & 1 deletion docs/databases/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ A red triangle on a sidebar item marks panes with missing required fields.
| **Host** | Server address. Defaults to `localhost` |
| **Port** | Server port. Pre-filled per database type |
| **Database** | Default database. Optional for MySQL/MariaDB; leave empty for service-level access |
| **Username** | Database username. Defaults to `root` (MySQL), `postgres` (PostgreSQL) |
| **Username** | Database username. Optional: leave it empty and the database uses its own default, which is your Mac login name for MySQL/MariaDB and PostgreSQL, and `default` for ClickHouse |
| **Password** | Stored in Keychain |
| **Prompt for password** | Skip saving the password. TablePro asks for it on every connect |
| **Use Password File** | PostgreSQL only. Reads credentials from `~/.pgpass` |
Expand Down
Loading