Skip to content
Open
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 @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- The sidebar can show every database on the server as an expandable tree. Switch a connection between the flat list and the tree from the View menu (Sidebar Layout); right-click a database or schema to set it active. Set the default layout for new connections in Settings, General. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139)
- A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254)
- PostgreSQL: PostGIS `geometry` and `geography` columns now render as WKT with SRID instead of raw hex. (#1458)

### Fixed

Expand Down
8 changes: 8 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ final class LibPQDriverCore: @unchecked Sendable {

var currentSchema: String = "public"

var onPostConnect: (@Sendable () async -> Void)?

var serverVersion: String? { libpqConnection?.serverVersion() }
var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 }

Expand Down Expand Up @@ -42,6 +44,8 @@ final class LibPQDriverCore: @unchecked Sendable {
let schema = schemaResult.rows.first?.first?.asText {
currentSchema = schema
}

await onPostConnect?()
}

func disconnect() {
Expand Down Expand Up @@ -86,6 +90,10 @@ final class LibPQDriverCore: @unchecked Sendable {
libpqConnection?.cancelCurrentQuery()
}

func setPostgisOidMap(_ map: [UInt32: String]) {
libpqConnection?.setPostgisOidMap(map)
}

func applyQueryTimeout(_ seconds: Int) async throws {
let ms = seconds * 1_000
_ = try await execute(query: "SET statement_timeout = '\(ms)'")
Expand Down
151 changes: 139 additions & 12 deletions Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
private var _cachedServerVersion: String?
private var _cachedServerVersionNumber: Int32 = 0
private var _isCancelled: Bool = false
private var _postgisOidMap: [UInt32: String] = [:]

var isConnected: Bool {
stateLock.lock()
Expand Down Expand Up @@ -247,6 +248,20 @@ final class LibPQPluginConnection: @unchecked Sendable {
}
}

// MARK: - PostGIS OID Map

func setPostgisOidMap(_ map: [UInt32: String]) {
stateLock.lock()
_postgisOidMap = map
stateLock.unlock()
}

private var postgisOidMap: [UInt32: String] {
stateLock.lock()
defer { stateLock.unlock() }
return _postgisOidMap
}

// MARK: - Query Cancellation

func cancelCurrentQuery() {
Expand Down Expand Up @@ -337,9 +352,8 @@ final class LibPQPluginConnection: @unchecked Sendable {
)

case PGRES_TUPLES_OK:
let queryResult = try fetchResults(from: result)
PQclear(result)
return queryResult
defer { PQclear(result) }
return try fetchResults(from: result)

default:
let error = getResultError(from: result)
Expand Down Expand Up @@ -441,9 +455,8 @@ final class LibPQPluginConnection: @unchecked Sendable {
)

case PGRES_TUPLES_OK:
let queryResult = try fetchResults(from: result)
PQclear(result)
return queryResult
defer { PQclear(result) }
return try fetchResults(from: result)

default:
let error = getResultError(from: result)
Expand Down Expand Up @@ -648,9 +661,34 @@ final class LibPQPluginConnection: @unchecked Sendable {
// MARK: - Result Parsing

private func fetchResults(from result: OpaquePointer) throws -> LibPQPluginQueryResult {
let numFields = Int(PQnfields(result))
let numRows = Int(PQntuples(result))
let metadata = readColumnMetadata(from: result)
let parsed = try parseRows(
from: result,
columns: metadata.columns,
columnOids: metadata.columnOids,
columnTypeNames: metadata.columnTypeNames
)

let oidMap = postgisOidMap
guard !oidMap.isEmpty else { return parsed }

let spatialColumns = metadata.columnOids.enumerated().compactMap { index, oid -> (index: Int, typeName: String)? in
guard let typeName = oidMap[oid] else { return nil }
return (index, typeName)
}
guard !spatialColumns.isEmpty else { return parsed }

return renderSpatialColumns(parsed, spatialColumns: spatialColumns)
}

private struct ColumnMetadata {
let columns: [String]
let columnOids: [UInt32]
let columnTypeNames: [String]
}

private func readColumnMetadata(from result: OpaquePointer) -> ColumnMetadata {
let numFields = Int(PQnfields(result))
var columns: [String] = []
var columnOids: [UInt32] = []
var columnTypeNames: [String] = []
Expand All @@ -664,11 +702,101 @@ final class LibPQPluginConnection: @unchecked Sendable {
} else {
columns.append("column_\(i)")
}
let oid = UInt32(PQftype(result, Int32(i)))
columnOids.append(oid)
columnTypeNames.append(pgOidToTypeName(oid))
}
return ColumnMetadata(columns: columns, columnOids: columnOids, columnTypeNames: columnTypeNames)
}

private func renderSpatialColumns(
_ result: LibPQPluginQueryResult,
spatialColumns: [(index: Int, typeName: String)]
) -> LibPQPluginQueryResult {
var rows = result.rows
var columnTypeNames = result.columnTypeNames

for column in spatialColumns {
if column.index < columnTypeNames.count {
columnTypeNames[column.index] = column.typeName
}

guard let query = PostGISSpatialRewrite.conversionQuery(forTypeName: column.typeName) else { continue }

let hexValues: [String?] = rows.map { row in
guard column.index < row.count, case let .text(hex) = row[column.index] else { return nil }
return hex
}
guard hexValues.contains(where: { $0 != nil }) else { continue }

guard let converted = convertSpatialValues(hexValues, query: query),
converted.count == hexValues.count else {
logger.warning("PostGIS value conversion failed for column \(column.index); keeping raw hex")
continue
}

for (rowIndex, value) in converted.enumerated() where column.index < rows[rowIndex].count {
rows[rowIndex][column.index] = value
}
}

return LibPQPluginQueryResult(
columns: result.columns,
columnOids: result.columnOids,
columnTypeNames: columnTypeNames,
rows: rows,
affectedRows: result.affectedRows,
commandTag: result.commandTag,
isTruncated: result.isTruncated
)
}

private func convertSpatialValues(_ hexValues: [String?], query: String) -> [PluginCellValue]? {
stateLock.lock()
let conn = self.conn
stateLock.unlock()
guard let conn else { return nil }

let arrayLiteral = PostGISSpatialRewrite.arrayLiteral(from: hexValues)
guard let paramCStr = strdup(arrayLiteral) else { return nil }
defer { free(paramCStr) }

let paramValues: [UnsafePointer<CChar>?] = [UnsafePointer(paramCStr)]
let result: OpaquePointer? = query.withCString { queryPtr in
PQexecParams(conn, queryPtr, 1, nil, paramValues, nil, nil, 0)
}

guard let result, PQresultStatus(result) == PGRES_TUPLES_OK else {
if let result { PQclear(result) }
return nil
}
defer { PQclear(result) }

let oid = PQftype(result, Int32(i))
columnOids.append(UInt32(oid))
columnTypeNames.append(pgOidToTypeName(UInt32(oid)))
let rowCount = Int(PQntuples(result))
var converted: [PluginCellValue] = []
converted.reserveCapacity(rowCount)
for rowIndex in 0..<rowCount {
if PQgetisnull(result, Int32(rowIndex), 0) == 1 {
converted.append(.null)
} else if let valuePtr = PQgetvalue(result, Int32(rowIndex), 0) {
let length = Int(PQgetlength(result, Int32(rowIndex), 0))
let bufferPtr = UnsafeRawBufferPointer(start: valuePtr, count: length)
converted.append(.text(String(bytes: bufferPtr, encoding: .utf8) ?? ""))
} else {
converted.append(.null)
}
}
return converted
}

private func parseRows(
from result: OpaquePointer,
columns: [String],
columnOids: [UInt32],
columnTypeNames: [String]
) throws -> LibPQPluginQueryResult {
let numFields = columns.count
let numRows = Int(PQntuples(result))

let maxRows = PluginRowLimits.emergencyMax
let effectiveRowCount = min(numRows, maxRows)
Expand All @@ -683,7 +811,6 @@ final class LibPQPluginConnection: @unchecked Sendable {
if shouldCancel { _isCancelled = false }
stateLock.unlock()
if shouldCancel {
PQclear(result)
throw LibPQPluginError(message: "Query cancelled", sqlState: nil, detail: nil)
}

Expand Down
43 changes: 43 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// PostGISSpatialRewrite.swift
// PostgreSQLDriverPlugin
//
// PostGIS rendering support. Geometry and geography values arrive from libpq as
// raw EWKB hex (e.g. "0101000020E6100000..."). To surface them as readable WKT
// with SRID, we probe pg_type for the dynamic PostGIS OIDs at connect time and,
// when a result set contains spatial columns, convert the already-fetched hex
// values with a separate side-effect-free query. The original user statement is
// never re-executed: the conversion runs ST_AsEWKT over an array of the fetched
// values, so it can't double-apply side effects and works the same regardless of
// whether the query was parameterized.
//

import Foundation

enum PostGISSpatialRewrite {
static let probeQuery = "SELECT oid, typname FROM pg_type WHERE typname IN ('geometry', 'geography')"

static let geometryConversionQuery =
"SELECT ST_AsEWKT(t::geometry) FROM unnest($1::text[]) WITH ORDINALITY AS x(t, ord) ORDER BY ord"
static let geographyConversionQuery =
"SELECT ST_AsEWKT(t::geography) FROM unnest($1::text[]) WITH ORDINALITY AS x(t, ord) ORDER BY ord"

static func conversionQuery(forTypeName typeName: String) -> String? {
switch typeName {
case "geometry": return geometryConversionQuery
case "geography": return geographyConversionQuery
default: return nil
}
}

static func arrayLiteral(from values: [String?]) -> String {
let elements = values.map { value -> String in
guard let value else { return "NULL" }
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
return "{\(elements.joined(separator: ","))}"
}
}
22 changes: 21 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
// MARK: - Connection

func connect() async throws {
core.onPostConnect = { [weak self] in
await self?.probeCatalogPresence()
await self?.probePostgisOids()
}
try await core.connect()
await probeCatalogPresence()
}

private func probeCatalogPresence() async {
Expand All @@ -60,6 +63,23 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
}
}

private func probePostgisOids() async {
do {
let result = try await core.execute(query: PostGISSpatialRewrite.probeQuery)
var map: [UInt32: String] = [:]
for row in result.rows {
guard row.count >= 2,
let oidText = row[0].asText,
let oid = UInt32(oidText),
let typname = row[1].asText else { continue }
map[oid] = typname
}
core.setPostgisOidMap(map)
} catch {
Self.logger.debug("PostGIS OID probe failed; spatial rewrite disabled for this session: \(error.localizedDescription)")
}
}

private func includesMaterializedViews() -> Bool {
catalogPresence?.hasMaterializedViews ?? versionedCapabilities.hasMaterializedViewsCatalog
}
Expand Down
Loading
Loading