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

Filter by extension

Filter by extension

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

## [Unreleased]

### Added

- Import connections and passwords from DataGrip, including SSH tunnels and SSL settings. The source app doesn't need to be running. (#1374)

## [0.43.3] - 2026-05-22

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//
// DataGripDataSourceParser.swift
// TablePro
//

import Foundation

struct DataGripDataSource {
let uuid: String
let name: String
let driverRef: String
let jdbcURL: String
let username: String
let groupName: String?
let ssh: DataGripSSHReference?
let ssl: DataGripSSLProperties?
}

/// A single `<data-source>` element. DataGrip splits one logical data source
/// across the shared `dataSources.xml` (driver, jdbc-url, group) and the
/// machine-local `dataSources.local.xml` (user name, ssh-properties,
/// ssl-properties). Fragments from both files merge by uuid before resolving.
struct DataGripDataSourceFragment {
let uuid: String
var name: String?
var driverRef: String?
var jdbcURL: String?
var username: String?
var groupName: String?
var ssh: DataGripSSHReference?
var ssl: DataGripSSLProperties?

mutating func merge(_ other: DataGripDataSourceFragment) {
name = other.name ?? name
driverRef = other.driverRef ?? driverRef
jdbcURL = other.jdbcURL ?? jdbcURL
username = other.username ?? username
groupName = other.groupName ?? groupName
ssh = other.ssh ?? ssh
ssl = other.ssl ?? ssl
}

func resolved() -> DataGripDataSource? {
guard let driverRef, !driverRef.isEmpty,
let jdbcURL, !jdbcURL.isEmpty else { return nil }
return DataGripDataSource(
uuid: uuid,
name: name ?? uuid,
driverRef: driverRef,
jdbcURL: jdbcURL,
username: username ?? "",
groupName: groupName,
ssh: ssh,
ssl: ssl
)
}
}

struct DataGripSSHReference {
let enabled: Bool
let configId: String?
let inlineHost: String?
let inlinePort: Int?
let inlineUser: String?
}

struct DataGripSSLProperties {
let mode: String?
let caCertPath: String?
let clientCertPath: String?
let clientKeyPath: String?
}

struct DataGripSSHConfig {
let id: String
let host: String
let port: Int?
let username: String
let authType: String?
let keyPath: String?
}

enum DataGripDataSourceParser {
static func parseFragments(_ data: Data) -> [DataGripDataSourceFragment] {
guard let document = try? XMLDocument(data: data),
let nodes = try? document.nodes(forXPath: "//data-source") else { return [] }

return nodes.compactMap { node in
guard let element = node as? XMLElement, let uuid = element.attr("uuid") else { return nil }
return parseFragment(element, uuid: uuid)
}
}

static func parseSSHConfigs(_ data: Data) -> [String: DataGripSSHConfig] {
guard let document = try? XMLDocument(data: data),
let nodes = try? document.nodes(forXPath: "//sshConfig") else { return [:] }

var result: [String: DataGripSSHConfig] = [:]
for node in nodes {
guard let element = node as? XMLElement,
let id = element.attr("id") else { continue }
let config = DataGripSSHConfig(
id: id,
host: element.attr("host") ?? "",
port: element.attr("port").flatMap { Int($0) },
username: element.attr("username") ?? "",
authType: element.attr("authType"),
keyPath: element.attr("keyPath").map { JetBrainsPathMacros.expand($0) }
)
result[id] = config
}
return result
}

// MARK: - Private

private static func parseFragment(_ element: XMLElement, uuid: String) -> DataGripDataSourceFragment {
var fragment = DataGripDataSourceFragment(uuid: uuid)
fragment.name = element.attr("name").flatMap { $0.isEmpty ? nil : $0 }
fragment.driverRef = element.childText("driver-ref")
fragment.jdbcURL = element.childText("jdbc-url").flatMap { $0.isEmpty ? nil : $0 }
fragment.username = element.childText("user-name").flatMap { $0.isEmpty ? nil : $0 }
fragment.groupName = element.attr("group-name").flatMap { $0.isEmpty ? nil : $0 }
fragment.ssh = parseSSHReference(element)
fragment.ssl = parseSSLProperties(element)
return fragment
}

private static func parseSSHReference(_ element: XMLElement) -> DataGripSSHReference? {
guard let ssh = element.elements(forName: "ssh-properties").first else { return nil }

let enabled = (ssh.childText("enabled") ?? ssh.attr("enabled")) == "true"
guard enabled else { return nil }

let configId = ssh.childText("ssh-config-id") ?? ssh.attr("ssh-config-id")
return DataGripSSHReference(
enabled: true,
configId: configId.flatMap { $0.isEmpty ? nil : $0 },
inlineHost: ssh.attr("host"),
inlinePort: ssh.attr("port").flatMap { Int($0) },
inlineUser: ssh.attr("user") ?? ssh.attr("username")
)
}

private static func parseSSLProperties(_ element: XMLElement) -> DataGripSSLProperties? {
guard let ssl = element.elements(forName: "ssl-config").first,
ssl.childText("enabled") == "true" else { return nil }

return DataGripSSLProperties(
mode: ssl.childText("mode"),
caCertPath: ssl.certPath("ca-cert"),
clientCertPath: ssl.certPath("client-cert"),
clientKeyPath: ssl.certPath("client-key")
)
}
}

private extension XMLElement {
func childText(_ name: String) -> String? {
elements(forName: name).first?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
}

func attr(_ name: String) -> String? {
attribute(forName: name)?.stringValue
}

func certPath(_ name: String) -> String? {
childText(name).flatMap { $0.isEmpty ? nil : JetBrainsPathMacros.expand($0) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
//
// JDBCConnectionString.swift
// TablePro
//

import Foundation

enum JDBCConnectionString {
struct Endpoint {
let host: String
let port: Int?
let database: String
}

static func parse(url: String, subprotocol: String) -> Endpoint? {
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.lowercased().hasPrefix("jdbc:") else { return nil }
let body = String(trimmed.dropFirst("jdbc:".count))

switch subprotocol.lowercased() {
case "sqlserver", "jtds":
return parseSQLServer(body)
case "oracle":
return parseOracle(body)
case "sqlite", "duckdb", "h2":
return parseFilePath(body, subprotocol: subprotocol)
case "bigquery":
return parseBigQuery(body)
default:
return parseAuthority(body)
}
}

// MARK: - Authority Form

/// jdbc:<sub>://[user[:pass]@]host[:port][/database][?params]
/// Covers MySQL, MariaDB, PostgreSQL, ClickHouse, MongoDB, Cassandra, Redis.
private static func parseAuthority(_ body: String) -> Endpoint? {
guard let schemeRange = body.range(of: "://") else { return nil }
var remainder = String(body[schemeRange.upperBound...])

remainder = stripQuery(from: remainder)
let pathSplit = splitOnce(remainder, separator: "/")
let authority = stripUserInfo(pathSplit.head)
let database = pathSplit.tail ?? ""

let firstHost = authority.components(separatedBy: ",").first ?? authority
let (host, port) = parseHostPort(firstHost)
guard !host.isEmpty else { return nil }
return Endpoint(host: host, port: port, database: database)
}

// MARK: - SQL Server Form

/// jdbc:sqlserver://host[\instance][:port][;prop=value;...]
/// jdbc:jtds:sqlserver://host:port/database
private static func parseSQLServer(_ body: String) -> Endpoint? {
var remainder = body
if remainder.lowercased().hasPrefix("jtds:") {
remainder = String(remainder.dropFirst("jtds:".count))
}
if remainder.lowercased().hasPrefix("sqlserver:") {
remainder = String(remainder.dropFirst("sqlserver:".count))
}
guard remainder.hasPrefix("//") else { return nil }
remainder = String(remainder.dropFirst(2))

let semicolonSplit = splitOnce(remainder, separator: ";")
let properties = parseSemicolonProperties(semicolonSplit.tail ?? "")

let beforeProps = semicolonSplit.head
let slashSplit = splitOnce(beforeProps, separator: "/")
var authority = slashSplit.head
var database = slashSplit.tail ?? ""

if let backslash = authority.firstIndex(of: "\\") {
authority = String(authority[..<backslash])
}

let (host, port) = parseHostPort(authority)
guard !host.isEmpty else { return nil }

if database.isEmpty {
database = properties["databasename"] ?? properties["database"] ?? ""
}
return Endpoint(host: host, port: port, database: database)
}

// MARK: - Oracle Form

/// jdbc:oracle:thin:@host:port:SID
/// jdbc:oracle:thin:@//host:port/SERVICE_NAME
/// jdbc:oracle:thin:@host:port/SERVICE_NAME
private static func parseOracle(_ body: String) -> Endpoint? {
guard let atIndex = body.firstIndex(of: "@") else { return nil }
var descriptor = String(body[body.index(after: atIndex)...])
descriptor = stripQuery(from: descriptor)

if descriptor.hasPrefix("//") {
descriptor = String(descriptor.dropFirst(2))
let slashSplit = splitOnce(descriptor, separator: "/")
let (host, port) = parseHostPort(slashSplit.head)
guard !host.isEmpty else { return nil }
return Endpoint(host: host, port: port, database: slashSplit.tail ?? "")
}

let parts = descriptor.components(separatedBy: ":")
guard parts.count >= 2, !parts[0].isEmpty else {
let slashSplit = splitOnce(descriptor, separator: "/")
let (host, port) = parseHostPort(slashSplit.head)
guard !host.isEmpty else { return nil }
return Endpoint(host: host, port: port, database: slashSplit.tail ?? "")
}

let host = parts[0]
let portAndRest = parts[1]
let slashSplit = splitOnce(portAndRest, separator: "/")
let port = Int(slashSplit.head)

if let serviceName = slashSplit.tail {
return Endpoint(host: host, port: port, database: serviceName)
}
let sid = parts.count >= 3 ? parts[2] : ""
return Endpoint(host: host, port: port, database: sid)
}

// MARK: - File Form

/// jdbc:sqlite:/path/to/file.db, jdbc:duckdb:/path/to/file.duckdb
private static func parseFilePath(_ body: String, subprotocol: String) -> Endpoint? {
guard body.lowercased().hasPrefix(subprotocol.lowercased() + ":") else {
return Endpoint(host: "", port: nil, database: stripQuery(from: body))
}
var path = String(body.dropFirst(subprotocol.count + 1))
path = stripQuery(from: path)
return Endpoint(host: "", port: nil, database: path)
}

// MARK: - BigQuery Form

/// jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project;...
private static func parseBigQuery(_ body: String) -> Endpoint? {
guard body.hasPrefix("//") else { return nil }
let remainder = String(body.dropFirst(2))
let semicolonSplit = splitOnce(remainder, separator: ";")
let properties = parseSemicolonProperties(semicolonSplit.tail ?? "")
let project = properties["projectid"] ?? properties["project"] ?? ""
return Endpoint(host: semicolonSplit.head, port: nil, database: project)
}

// MARK: - Helpers

private static func parseHostPort(_ authority: String) -> (host: String, port: Int?) {
if authority.hasPrefix("[") {
guard let closing = authority.firstIndex(of: "]") else {
return (authority, nil)
}
let host = String(authority[authority.index(after: authority.startIndex)..<closing])
let after = authority[authority.index(after: closing)...]
if after.hasPrefix(":") {
return (host, Int(after.dropFirst()))
}
return (host, nil)
}

guard let colon = authority.lastIndex(of: ":") else {
return (authority, nil)
}
let host = String(authority[..<colon])
let port = Int(authority[authority.index(after: colon)...])
return (host, port)
}

private static func stripUserInfo(_ authority: String) -> String {
guard let atIndex = authority.lastIndex(of: "@") else { return authority }
return String(authority[authority.index(after: atIndex)...])
}

private static func stripQuery(from value: String) -> String {
if let question = value.firstIndex(of: "?") {
return String(value[..<question])
}
return value
}

private static func splitOnce(_ value: String, separator: Character) -> (head: String, tail: String?) {
guard let index = value.firstIndex(of: separator) else { return (value, nil) }
return (String(value[..<index]), String(value[value.index(after: index)...]))
}

private static func parseSemicolonProperties(_ value: String) -> [String: String] {
var result: [String: String] = [:]
for pair in value.components(separatedBy: ";") where !pair.isEmpty {
let kv = splitOnce(pair, separator: "=")
guard let rawValue = kv.tail else { continue }
result[kv.head.lowercased().trimmingCharacters(in: .whitespaces)] = rawValue
}
return result
}
}
Loading
Loading