diff --git a/CHANGELOG.md b/CHANGELOG.md index 845b79cd1..00390c922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A plus button in the bottom bar of the Tables sidebar opens a menu to create a new table or view, without right-clicking. It's disabled while safe mode blocks writes. - 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) - Import connections from Navicat: export a connections file from Navicat (File, Export Connections), then pick it under Import from Other App. SSH tunnel and SSL settings come across, and saved passwords are decrypted during import. (#1485) ### Changed diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index d5049a14e..2389abf18 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -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 } @@ -42,6 +44,8 @@ final class LibPQDriverCore: @unchecked Sendable { let schema = schemaResult.rows.first?.first?.asText { currentSchema = schema } + + await onPostConnect?() } func disconnect() { @@ -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)'") diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 730a6baad..83fadd793 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -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() @@ -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() { @@ -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) @@ -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) @@ -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] = [] @@ -664,11 +702,102 @@ 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 hexValues[rowIndex] != nil && 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?] = [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.. LibPQPluginQueryResult { + let numFields = columns.count + let numRows = Int(PQntuples(result)) let maxRows = PluginRowLimits.emergencyMax let effectiveRowCount = min(numRows, maxRows) @@ -683,7 +812,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) } diff --git a/Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift b/Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift new file mode 100644 index 000000000..1085ba891 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift @@ -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: ","))}" + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 15ee7f495..dfa3cd330 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -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 { @@ -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 } diff --git a/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift new file mode 120000 index 000000000..e01f56611 --- /dev/null +++ b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift @@ -0,0 +1 @@ +../../Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift \ No newline at end of file diff --git a/TableProTests/Plugins/PostGISSpatialRewriteTests.swift b/TableProTests/Plugins/PostGISSpatialRewriteTests.swift new file mode 100644 index 000000000..c636b9a1a --- /dev/null +++ b/TableProTests/Plugins/PostGISSpatialRewriteTests.swift @@ -0,0 +1,88 @@ +// +// PostGISSpatialRewriteTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("PostGISSpatialRewrite.conversionQuery") +struct PostGISConversionQueryTests { + @Test("geometry maps to the geometry conversion query") + func geometry() { + #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "geometry") + == PostGISSpatialRewrite.geometryConversionQuery) + } + + @Test("geography maps to the geography conversion query") + func geography() { + #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "geography") + == PostGISSpatialRewrite.geographyConversionQuery) + } + + @Test("Unknown type name returns nil") + func unknown() { + #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "text") == nil) + #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "raster") == nil) + #expect(PostGISSpatialRewrite.conversionQuery(forTypeName: "") == nil) + } + + @Test("geometry query applies ST_AsEWKT over a text array parameter cast per element") + func geometryQueryShape() { + let query = PostGISSpatialRewrite.geometryConversionQuery + #expect(query.contains("ST_AsEWKT(t::geometry)")) + #expect(query.contains("unnest($1::text[])")) + #expect(query.contains("ORDER BY ord")) + } + + @Test("geography query casts each element to geography") + func geographyQueryShape() { + let query = PostGISSpatialRewrite.geographyConversionQuery + #expect(query.contains("ST_AsEWKT(t::geography)")) + #expect(query.contains("unnest($1::text[])")) + } + + @Test("Conversion query reads a single bound parameter, never the user statement") + func singleParameter() { + #expect(PostGISSpatialRewrite.geometryConversionQuery.contains("$1")) + #expect(!PostGISSpatialRewrite.geometryConversionQuery.contains("$2")) + } +} + +@Suite("PostGISSpatialRewrite.arrayLiteral") +struct PostGISArrayLiteralTests { + @Test("Single hex value is quoted") + func singleValue() { + #expect(PostGISSpatialRewrite.arrayLiteral(from: ["0101"]) == "{\"0101\"}") + } + + @Test("Multiple values are comma-separated and order-preserved") + func multipleValues() { + #expect(PostGISSpatialRewrite.arrayLiteral(from: ["AA", "BB", "CC"]) == "{\"AA\",\"BB\",\"CC\"}") + } + + @Test("Nil becomes an unquoted NULL element") + func nullElement() { + #expect(PostGISSpatialRewrite.arrayLiteral(from: ["AA", nil, "CC"]) == "{\"AA\",NULL,\"CC\"}") + } + + @Test("All-nil values produce an all-NULL literal") + func allNull() { + #expect(PostGISSpatialRewrite.arrayLiteral(from: [nil, nil]) == "{NULL,NULL}") + } + + @Test("Empty input is an empty array literal") + func empty() { + #expect(PostGISSpatialRewrite.arrayLiteral(from: []) == "{}") + } + + @Test("Embedded double quote is backslash-escaped") + func embeddedQuote() { + #expect(PostGISSpatialRewrite.arrayLiteral(from: ["a\"b"]) == "{\"a\\\"b\"}") + } + + @Test("Embedded backslash is doubled") + func embeddedBackslash() { + #expect(PostGISSpatialRewrite.arrayLiteral(from: ["a\\b"]) == "{\"a\\\\b\"}") + } +} diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index d9e6a7277..051f5d9ef 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -111,6 +111,10 @@ ORDER BY rank DESC; Supports `jsonb` (formatted JSON), `array`, `uuid`, `inet` (IP), `timestamp with time zone`, `interval`, `bytea` (binary). +### PostGIS + +If the PostGIS extension is installed, `geometry` and `geography` columns render as EWKT with the SRID preserved (`SRID=4326;POINT(-73 40.7237)`) instead of the raw EWKB hex libpq returns. TablePro detects spatial columns from a one-time `pg_type` lookup at connect time, then converts the fetched values with `ST_AsEWKT(...)`. Your query is never re-run, so parameterized, multi-statement, and any other query shape all render EWKT. NULL stays NULL and `POINT EMPTY` round-trips. If the conversion fails, the raw hex is kept without an error. + ## Troubleshooting **Connection refused**: Check server is running (`brew services start postgresql@16`), verify `listen_addresses` in `postgresql.conf`, ensure firewall allows port 5432.