From 7188ddf4bf61956b4deb6fe6e21a8ee6b8f33ca4 Mon Sep 17 00:00:00 2001 From: Shubhank Saxena Date: Thu, 28 May 2026 14:31:58 -0400 Subject: [PATCH 01/10] feat(plugin-postgresql): render PostGIS geometry/geography as EWKT (#1458) --- .../LibPQDriverCore.swift | 4 + .../LibPQPluginConnection.swift | 124 +++++++++-- .../PostgreSQLPluginDriver+Spatial.swift | 205 ++++++++++++++++++ .../PostgreSQLPluginDriver.swift | 18 ++ 4 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index d5049a14e..540916cd3 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -86,6 +86,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..20dac0c92 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, originalQuery: localQuery) 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, originalQuery: nil) default: let error = getResultError(from: result) @@ -647,10 +660,44 @@ 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)) + private func fetchResults(from result: OpaquePointer, originalQuery: String?) throws -> LibPQPluginQueryResult { + let metadata = readColumnMetadata(from: result) + + let oidMap = postgisOidMap + if !oidMap.isEmpty, let query = originalQuery { + let spatialIndices = Set(metadata.columnOids.enumerated().compactMap { idx, oid in + oidMap[oid] != nil ? idx : nil + }) + if !spatialIndices.isEmpty, + PostGISSpatialRewrite.isSafeToWrap(query: query, columns: metadata.columns) { + if let rewritten = try executeSpatialRewrite( + originalQuery: query, + columns: metadata.columns, + originalColumnOids: metadata.columnOids, + spatialIndices: spatialIndices, + oidMap: oidMap + ) { + return rewritten + } + } + } + + return try parseRows( + from: result, + columns: metadata.columns, + columnOids: metadata.columnOids, + columnTypeNames: metadata.columnTypeNames + ) + } + + 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 +711,63 @@ 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 executeSpatialRewrite( + originalQuery: String, + columns: [String], + originalColumnOids: [UInt32], + spatialIndices: Set, + oidMap: [UInt32: String] + ) throws -> LibPQPluginQueryResult? { + let wrappedQuery = PostGISSpatialRewrite.buildWrappedQuery( + originalQuery: originalQuery, + columns: columns, + spatialIndices: spatialIndices + ) + + stateLock.lock() + let conn = self.conn + stateLock.unlock() + guard let conn else { return nil } - let oid = PQftype(result, Int32(i)) - columnOids.append(UInt32(oid)) - columnTypeNames.append(pgOidToTypeName(UInt32(oid))) + let wrappedResult = wrappedQuery.withCString { PQexec(conn, $0) } + guard let wrappedResult, PQresultStatus(wrappedResult) == PGRES_TUPLES_OK else { + if let wrappedResult { PQclear(wrappedResult) } + logger.warning("PostGIS spatial rewrite query failed; falling back to raw hex output") + return nil } + defer { PQclear(wrappedResult) } + + let wrappedMetadata = readColumnMetadata(from: wrappedResult) + let overriddenTypeNames = originalColumnOids.enumerated().map { idx, originalOid -> String in + if spatialIndices.contains(idx), let spatialName = oidMap[originalOid] { + return spatialName + } + return pgOidToTypeName(originalOid) + } + + return try parseRows( + from: wrappedResult, + columns: columns, + columnOids: wrappedMetadata.columnOids, + columnTypeNames: overriddenTypeNames + ) + } + + 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) @@ -683,7 +782,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/PostgreSQLPluginDriver+Spatial.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift new file mode 100644 index 000000000..22b090716 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift @@ -0,0 +1,205 @@ +// +// PostgreSQLPluginDriver+Spatial.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, re-execute the query wrapped in a +// projection that applies ST_AsEWKT to each spatial column. +// + +import Foundation + +enum PostGISSpatialRewrite { + static let probeQuery = "SELECT oid, typname FROM pg_type WHERE typname IN ('geometry', 'geography')" + + static func quoteIdentifier(_ ident: String) -> String { + "\"\(ident.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + static func buildWrappedQuery( + originalQuery: String, + columns: [String], + spatialIndices: Set + ) -> String { + var trimmed = originalQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix(";") { + trimmed = String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + + let projections = columns.enumerated().map { index, name -> String in + let quoted = quoteIdentifier(name) + if spatialIndices.contains(index) { + return "ST_AsEWKT(\(quoted)) AS \(quoted)" + } + return quoted + } + + return "SELECT \(projections.joined(separator: ", ")) FROM (\(trimmed)) AS _tp_rewrite" + } + + static func isSafeToWrap(query: String, columns: [String]) -> Bool { + guard hasUniqueColumnNames(columns) else { return false } + guard startsWithSelectWithOrValues(query) else { return false } + return !hasTopLevelStatementSeparator(query) + } + + static func hasUniqueColumnNames(_ columns: [String]) -> Bool { + Set(columns).count == columns.count + } + + static func startsWithSelectWithOrValues(_ query: String) -> Bool { + let chars = Array(query) + var i = 0 + while i < chars.count { + let c = chars[i] + if c.isWhitespace { + i += 1 + continue + } + if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { + while i < chars.count, chars[i] != "\n" { i += 1 } + continue + } + if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { + i = skipBlockComment(chars, startingAt: i) + continue + } + break + } + + var ident = "" + while i < chars.count, chars[i].isLetter { + ident.append(chars[i]) + i += 1 + } + let upper = ident.uppercased() + return upper == "SELECT" || upper == "WITH" || upper == "VALUES" + } + + static func hasTopLevelStatementSeparator(_ query: String) -> Bool { + let chars = Array(query) + var i = 0 + var sawTerminator = false + while i < chars.count { + let c = chars[i] + + if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { + while i < chars.count, chars[i] != "\n" { i += 1 } + continue + } + if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { + i = skipBlockComment(chars, startingAt: i) + continue + } + if c == "'" { + i = skipSingleQuoted(chars, startingAt: i) + continue + } + if c == "\"" { + i = skipDoubleQuoted(chars, startingAt: i) + continue + } + if c == "$", let endOfDollar = skipDollarQuoted(chars, startingAt: i) { + i = endOfDollar + continue + } + + if c == ";" { + sawTerminator = true + i += 1 + continue + } + + if sawTerminator, !c.isWhitespace { + return true + } + + i += 1 + } + return false + } + + private static func skipBlockComment(_ chars: [Character], startingAt start: Int) -> Int { + var i = start + 2 + var depth = 1 + while i < chars.count, depth > 0 { + if chars[i] == "/", i + 1 < chars.count, chars[i + 1] == "*" { + depth += 1 + i += 2 + } else if chars[i] == "*", i + 1 < chars.count, chars[i + 1] == "/" { + depth -= 1 + i += 2 + } else { + i += 1 + } + } + return i + } + + private static func skipSingleQuoted(_ chars: [Character], startingAt start: Int) -> Int { + var i = start + 1 + while i < chars.count { + if chars[i] == "'" { + if i + 1 < chars.count, chars[i + 1] == "'" { + i += 2 + } else { + return i + 1 + } + } else { + i += 1 + } + } + return i + } + + private static func skipDoubleQuoted(_ chars: [Character], startingAt start: Int) -> Int { + var i = start + 1 + while i < chars.count { + if chars[i] == "\"" { + if i + 1 < chars.count, chars[i + 1] == "\"" { + i += 2 + } else { + return i + 1 + } + } else { + i += 1 + } + } + return i + } + + private static func skipDollarQuoted(_ chars: [Character], startingAt start: Int) -> Int? { + var tagEnd = start + 1 + while tagEnd < chars.count { + let ch = chars[tagEnd] + if ch == "$" { break } + let isFirst = tagEnd == start + 1 + let validFirst = ch.isLetter || ch == "_" + let validRest = validFirst || ch.isNumber + if isFirst ? !validFirst : !validRest { + return nil + } + tagEnd += 1 + } + guard tagEnd < chars.count, chars[tagEnd] == "$" else { return nil } + + let tag = Array(chars[start...tagEnd]) + var i = tagEnd + 1 + while i + tag.count <= chars.count { + if chars[i] == "$" { + var matches = true + for j in 0..= 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 } From 30d0e033ea767d27d8e43a3336af9dbe43381c59 Mon Sep 17 00:00:00 2001 From: Shubhank Saxena Date: Thu, 28 May 2026 14:32:03 -0400 Subject: [PATCH 02/10] test(plugin-postgresql): cover safe-to-wrap predicate and identifier quoting --- .../PostGISSpatialRewrite.swift | 205 +++++++++++ .../Plugins/PostGISSpatialRewriteTests.swift | 327 ++++++++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 TableProTests/PluginTestSources/PostGISSpatialRewrite.swift create mode 100644 TableProTests/Plugins/PostGISSpatialRewriteTests.swift diff --git a/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift new file mode 100644 index 000000000..22b090716 --- /dev/null +++ b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift @@ -0,0 +1,205 @@ +// +// PostgreSQLPluginDriver+Spatial.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, re-execute the query wrapped in a +// projection that applies ST_AsEWKT to each spatial column. +// + +import Foundation + +enum PostGISSpatialRewrite { + static let probeQuery = "SELECT oid, typname FROM pg_type WHERE typname IN ('geometry', 'geography')" + + static func quoteIdentifier(_ ident: String) -> String { + "\"\(ident.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + static func buildWrappedQuery( + originalQuery: String, + columns: [String], + spatialIndices: Set + ) -> String { + var trimmed = originalQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix(";") { + trimmed = String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + + let projections = columns.enumerated().map { index, name -> String in + let quoted = quoteIdentifier(name) + if spatialIndices.contains(index) { + return "ST_AsEWKT(\(quoted)) AS \(quoted)" + } + return quoted + } + + return "SELECT \(projections.joined(separator: ", ")) FROM (\(trimmed)) AS _tp_rewrite" + } + + static func isSafeToWrap(query: String, columns: [String]) -> Bool { + guard hasUniqueColumnNames(columns) else { return false } + guard startsWithSelectWithOrValues(query) else { return false } + return !hasTopLevelStatementSeparator(query) + } + + static func hasUniqueColumnNames(_ columns: [String]) -> Bool { + Set(columns).count == columns.count + } + + static func startsWithSelectWithOrValues(_ query: String) -> Bool { + let chars = Array(query) + var i = 0 + while i < chars.count { + let c = chars[i] + if c.isWhitespace { + i += 1 + continue + } + if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { + while i < chars.count, chars[i] != "\n" { i += 1 } + continue + } + if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { + i = skipBlockComment(chars, startingAt: i) + continue + } + break + } + + var ident = "" + while i < chars.count, chars[i].isLetter { + ident.append(chars[i]) + i += 1 + } + let upper = ident.uppercased() + return upper == "SELECT" || upper == "WITH" || upper == "VALUES" + } + + static func hasTopLevelStatementSeparator(_ query: String) -> Bool { + let chars = Array(query) + var i = 0 + var sawTerminator = false + while i < chars.count { + let c = chars[i] + + if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { + while i < chars.count, chars[i] != "\n" { i += 1 } + continue + } + if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { + i = skipBlockComment(chars, startingAt: i) + continue + } + if c == "'" { + i = skipSingleQuoted(chars, startingAt: i) + continue + } + if c == "\"" { + i = skipDoubleQuoted(chars, startingAt: i) + continue + } + if c == "$", let endOfDollar = skipDollarQuoted(chars, startingAt: i) { + i = endOfDollar + continue + } + + if c == ";" { + sawTerminator = true + i += 1 + continue + } + + if sawTerminator, !c.isWhitespace { + return true + } + + i += 1 + } + return false + } + + private static func skipBlockComment(_ chars: [Character], startingAt start: Int) -> Int { + var i = start + 2 + var depth = 1 + while i < chars.count, depth > 0 { + if chars[i] == "/", i + 1 < chars.count, chars[i + 1] == "*" { + depth += 1 + i += 2 + } else if chars[i] == "*", i + 1 < chars.count, chars[i + 1] == "/" { + depth -= 1 + i += 2 + } else { + i += 1 + } + } + return i + } + + private static func skipSingleQuoted(_ chars: [Character], startingAt start: Int) -> Int { + var i = start + 1 + while i < chars.count { + if chars[i] == "'" { + if i + 1 < chars.count, chars[i + 1] == "'" { + i += 2 + } else { + return i + 1 + } + } else { + i += 1 + } + } + return i + } + + private static func skipDoubleQuoted(_ chars: [Character], startingAt start: Int) -> Int { + var i = start + 1 + while i < chars.count { + if chars[i] == "\"" { + if i + 1 < chars.count, chars[i + 1] == "\"" { + i += 2 + } else { + return i + 1 + } + } else { + i += 1 + } + } + return i + } + + private static func skipDollarQuoted(_ chars: [Character], startingAt start: Int) -> Int? { + var tagEnd = start + 1 + while tagEnd < chars.count { + let ch = chars[tagEnd] + if ch == "$" { break } + let isFirst = tagEnd == start + 1 + let validFirst = ch.isLetter || ch == "_" + let validRest = validFirst || ch.isNumber + if isFirst ? !validFirst : !validRest { + return nil + } + tagEnd += 1 + } + guard tagEnd < chars.count, chars[tagEnd] == "$" else { return nil } + + let tag = Array(chars[start...tagEnd]) + var i = tagEnd + 1 + while i + tag.count <= chars.count { + if chars[i] == "$" { + var matches = true + for j in 0.. Date: Thu, 28 May 2026 14:32:07 -0400 Subject: [PATCH 03/10] docs(postgresql): note PostGIS rendering and changelog entry (#1458) --- CHANGELOG.md | 1 + docs/databases/postgresql.mdx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ead989d..299b9ff76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index d9e6a7277..e4041f2e9 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 and wraps the query with `ST_AsEWKT(...)` before fetching. Queries that can't be safely wrapped (multi-statement scripts, duplicate output column names, non-`SELECT`/`WITH`/`VALUES` shapes) fall back to raw hex without error. + ## Troubleshooting **Connection refused**: Check server is running (`brew services start postgresql@16`), verify `listen_addresses` in `postgresql.conf`, ensure firewall allows port 5432. From 704439d03d1986a4b0b7b5ead598b1a8dc8e66d6 Mon Sep 17 00:00:00 2001 From: Shubhank Saxena Date: Thu, 28 May 2026 15:13:08 -0400 Subject: [PATCH 04/10] fix(plugin-postgresql): skip PostGIS rewrite for queries with side effects --- .../PostgreSQLPluginDriver+Spatial.swift | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift index 22b090716..3ae5e815b 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift @@ -42,7 +42,76 @@ enum PostGISSpatialRewrite { static func isSafeToWrap(query: String, columns: [String]) -> Bool { guard hasUniqueColumnNames(columns) else { return false } guard startsWithSelectWithOrValues(query) else { return false } - return !hasTopLevelStatementSeparator(query) + guard !hasTopLevelStatementSeparator(query) else { return false } + return !containsSideEffectKeyword(query) + } + + static let sideEffectTokens: Set = [ + "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "COPY", + "NEXTVAL", "SETVAL" + ] + + static func containsSideEffectKeyword(_ query: String) -> Bool { + var found = false + forEachUnquotedToken(in: query) { token in + if sideEffectTokens.contains(token.uppercased()) { + found = true + } + } + return found + } + + private static func forEachUnquotedToken(in query: String, _ visit: (String) -> Void) { + let chars = Array(query) + var i = 0 + var token = "" + + func flushToken() { + if !token.isEmpty { + visit(token) + token = "" + } + } + + while i < chars.count { + let c = chars[i] + + if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { + flushToken() + while i < chars.count, chars[i] != "\n" { i += 1 } + continue + } + if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { + flushToken() + i = skipBlockComment(chars, startingAt: i) + continue + } + if c == "'" { + flushToken() + i = skipSingleQuoted(chars, startingAt: i) + continue + } + if c == "\"" { + flushToken() + i = skipDoubleQuoted(chars, startingAt: i) + continue + } + if c == "$", let endOfDollar = skipDollarQuoted(chars, startingAt: i) { + flushToken() + i = endOfDollar + continue + } + + if c.isLetter || c.isNumber || c == "_" { + token.append(c) + i += 1 + continue + } + + flushToken() + i += 1 + } + flushToken() } static func hasUniqueColumnNames(_ columns: [String]) -> Bool { From 6d0b144b76a0468493e5a1a64fa8db29ff035131 Mon Sep 17 00:00:00 2001 From: Shubhank Saxena Date: Thu, 28 May 2026 15:13:09 -0400 Subject: [PATCH 05/10] test(plugin-postgresql): cover side-effect keyword detection --- .../PostGISSpatialRewrite.swift | 71 +++++++++++++- .../Plugins/PostGISSpatialRewriteTests.swift | 93 +++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift index 22b090716..3ae5e815b 100644 --- a/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift +++ b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift @@ -42,7 +42,76 @@ enum PostGISSpatialRewrite { static func isSafeToWrap(query: String, columns: [String]) -> Bool { guard hasUniqueColumnNames(columns) else { return false } guard startsWithSelectWithOrValues(query) else { return false } - return !hasTopLevelStatementSeparator(query) + guard !hasTopLevelStatementSeparator(query) else { return false } + return !containsSideEffectKeyword(query) + } + + static let sideEffectTokens: Set = [ + "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "COPY", + "NEXTVAL", "SETVAL" + ] + + static func containsSideEffectKeyword(_ query: String) -> Bool { + var found = false + forEachUnquotedToken(in: query) { token in + if sideEffectTokens.contains(token.uppercased()) { + found = true + } + } + return found + } + + private static func forEachUnquotedToken(in query: String, _ visit: (String) -> Void) { + let chars = Array(query) + var i = 0 + var token = "" + + func flushToken() { + if !token.isEmpty { + visit(token) + token = "" + } + } + + while i < chars.count { + let c = chars[i] + + if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { + flushToken() + while i < chars.count, chars[i] != "\n" { i += 1 } + continue + } + if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { + flushToken() + i = skipBlockComment(chars, startingAt: i) + continue + } + if c == "'" { + flushToken() + i = skipSingleQuoted(chars, startingAt: i) + continue + } + if c == "\"" { + flushToken() + i = skipDoubleQuoted(chars, startingAt: i) + continue + } + if c == "$", let endOfDollar = skipDollarQuoted(chars, startingAt: i) { + flushToken() + i = endOfDollar + continue + } + + if c.isLetter || c.isNumber || c == "_" { + token.append(c) + i += 1 + continue + } + + flushToken() + i += 1 + } + flushToken() } static func hasUniqueColumnNames(_ columns: [String]) -> Bool { diff --git a/TableProTests/Plugins/PostGISSpatialRewriteTests.swift b/TableProTests/Plugins/PostGISSpatialRewriteTests.swift index 404b8004f..6d08985fb 100644 --- a/TableProTests/Plugins/PostGISSpatialRewriteTests.swift +++ b/TableProTests/Plugins/PostGISSpatialRewriteTests.swift @@ -223,6 +223,83 @@ struct PostGISSpatialRewriteSeparatorTests { } } +@Suite("PostGISSpatialRewrite.containsSideEffectKeyword") +struct PostGISSpatialRewriteSideEffectTests { + @Test("Plain SELECT has no side effects") + func plainSelectClean() { + #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT id, geom FROM places")) + } + + @Test("nextval call is flagged") + func nextvalFlagged() { + #expect(PostGISSpatialRewrite.containsSideEffectKeyword("SELECT nextval('s'), geom FROM places")) + } + + @Test("setval call is flagged") + func setvalFlagged() { + #expect(PostGISSpatialRewrite.containsSideEffectKeyword("SELECT setval('s', 1), geom FROM places")) + } + + @Test("nextval is flagged case-insensitively") + func nextvalCaseInsensitive() { + #expect(PostGISSpatialRewrite.containsSideEffectKeyword("SELECT NextVal('s'), geom FROM places")) + } + + @Test("Data-modifying CTE with INSERT is flagged") + func insertCteFlagged() { + let query = "WITH ins AS (INSERT INTO foo VALUES (1) RETURNING geom) SELECT geom FROM ins" + #expect(PostGISSpatialRewrite.containsSideEffectKeyword(query)) + } + + @Test("Data-modifying CTE with UPDATE is flagged") + func updateCteFlagged() { + let query = "WITH u AS (UPDATE places SET name='x' RETURNING point) SELECT point FROM u" + #expect(PostGISSpatialRewrite.containsSideEffectKeyword(query)) + } + + @Test("Data-modifying CTE with DELETE is flagged") + func deleteCteFlagged() { + let query = "WITH d AS (DELETE FROM places RETURNING point) SELECT point FROM d" + #expect(PostGISSpatialRewrite.containsSideEffectKeyword(query)) + } + + @Test("Data-modifying CTE with MERGE is flagged") + func mergeCteFlagged() { + let query = "WITH m AS (MERGE INTO foo USING bar ON foo.id = bar.id WHEN MATCHED THEN UPDATE SET x = 1 RETURNING geom) SELECT geom FROM m" + #expect(PostGISSpatialRewrite.containsSideEffectKeyword(query)) + } + + @Test("Table name containing 'update' substring is NOT flagged") + func tableNameSubstringNotFlagged() { + #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT geom FROM update_log")) + } + + @Test("nextval inside a string literal is NOT flagged") + func nextvalInStringNotFlagged() { + #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT 'nextval is fine' AS msg, geom FROM places")) + } + + @Test("INSERT inside a line comment is NOT flagged") + func insertInLineCommentNotFlagged() { + #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT geom FROM places -- INSERT INTO audit")) + } + + @Test("INSERT inside a block comment is NOT flagged") + func insertInBlockCommentNotFlagged() { + #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT geom /* INSERT INTO audit */ FROM places")) + } + + @Test("UPDATE inside a double-quoted identifier is NOT flagged") + func updateInQuotedIdentifierNotFlagged() { + #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT \"UPDATE\" FROM places")) + } + + @Test("nextval inside a dollar-quoted string is NOT flagged") + func nextvalInDollarQuoteNotFlagged() { + #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT $$nextval('s')$$ AS msg, geom FROM places")) + } +} + @Suite("PostGISSpatialRewrite.isSafeToWrap") struct PostGISSpatialRewriteSafeToWrapTests { @Test("Simple SELECT with unique columns is safe") @@ -261,6 +338,22 @@ struct PostGISSpatialRewriteSafeToWrapTests { columns: ["geom"] )) } + + @Test("Data-modifying CTE blocks wrap") + func dataModifyingCteUnsafe() { + #expect(!PostGISSpatialRewrite.isSafeToWrap( + query: "WITH ins AS (INSERT INTO foo VALUES (1) RETURNING geom) SELECT geom FROM ins", + columns: ["geom"] + )) + } + + @Test("nextval in SELECT blocks wrap") + func nextvalUnsafe() { + #expect(!PostGISSpatialRewrite.isSafeToWrap( + query: "SELECT nextval('s'), geom FROM places", + columns: ["nextval", "geom"] + )) + } } @Suite("PostGISSpatialRewrite.buildWrappedQuery") From af1fe74d11fe6a180e1b624a5b0d8b00c01daf39 Mon Sep 17 00:00:00 2001 From: Shubhank Saxena Date: Thu, 28 May 2026 15:39:46 -0400 Subject: [PATCH 06/10] fix(plugin-postgresql): block pg_notify and advisory-lock built-ins from PostGIS rewrite --- .../PostgreSQLPluginDriver+Spatial.swift | 7 +++++- .../PostGISSpatialRewrite.swift | 7 +++++- .../Plugins/PostGISSpatialRewriteTests.swift | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift index 3ae5e815b..6c027d387 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift @@ -48,7 +48,12 @@ enum PostGISSpatialRewrite { static let sideEffectTokens: Set = [ "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "COPY", - "NEXTVAL", "SETVAL" + "NEXTVAL", "SETVAL", + "PG_NOTIFY", "PG_LOGICAL_EMIT_MESSAGE", + "PG_ADVISORY_LOCK", "PG_ADVISORY_LOCK_SHARED", + "PG_ADVISORY_XACT_LOCK", "PG_ADVISORY_XACT_LOCK_SHARED", + "PG_TRY_ADVISORY_LOCK", "PG_TRY_ADVISORY_LOCK_SHARED", + "PG_TRY_ADVISORY_XACT_LOCK", "PG_TRY_ADVISORY_XACT_LOCK_SHARED" ] static func containsSideEffectKeyword(_ query: String) -> Bool { diff --git a/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift index 3ae5e815b..6c027d387 100644 --- a/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift +++ b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift @@ -48,7 +48,12 @@ enum PostGISSpatialRewrite { static let sideEffectTokens: Set = [ "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "COPY", - "NEXTVAL", "SETVAL" + "NEXTVAL", "SETVAL", + "PG_NOTIFY", "PG_LOGICAL_EMIT_MESSAGE", + "PG_ADVISORY_LOCK", "PG_ADVISORY_LOCK_SHARED", + "PG_ADVISORY_XACT_LOCK", "PG_ADVISORY_XACT_LOCK_SHARED", + "PG_TRY_ADVISORY_LOCK", "PG_TRY_ADVISORY_LOCK_SHARED", + "PG_TRY_ADVISORY_XACT_LOCK", "PG_TRY_ADVISORY_XACT_LOCK_SHARED" ] static func containsSideEffectKeyword(_ query: String) -> Bool { diff --git a/TableProTests/Plugins/PostGISSpatialRewriteTests.swift b/TableProTests/Plugins/PostGISSpatialRewriteTests.swift index 6d08985fb..b0975ce49 100644 --- a/TableProTests/Plugins/PostGISSpatialRewriteTests.swift +++ b/TableProTests/Plugins/PostGISSpatialRewriteTests.swift @@ -269,6 +269,31 @@ struct PostGISSpatialRewriteSideEffectTests { #expect(PostGISSpatialRewrite.containsSideEffectKeyword(query)) } + @Test("pg_notify call is flagged") + func pgNotifyFlagged() { + #expect(PostGISSpatialRewrite.containsSideEffectKeyword("SELECT pg_notify('c', 'm'), geom FROM places")) + } + + @Test("pg_advisory_lock call is flagged") + func pgAdvisoryLockFlagged() { + #expect(PostGISSpatialRewrite.containsSideEffectKeyword("SELECT pg_advisory_lock(1), geom FROM places")) + } + + @Test("pg_try_advisory_xact_lock variant is flagged") + func pgTryAdvisoryXactLockFlagged() { + #expect(PostGISSpatialRewrite.containsSideEffectKeyword("SELECT pg_try_advisory_xact_lock(1), geom FROM places")) + } + + @Test("pg_logical_emit_message is flagged") + func pgLogicalEmitMessageFlagged() { + #expect(PostGISSpatialRewrite.containsSideEffectKeyword("SELECT pg_logical_emit_message(true, 'c', 'm'), geom FROM places")) + } + + @Test("Read-only pg_ function is NOT flagged") + func pgReadOnlyFunctionNotFlagged() { + #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT pg_typeof(geom), geom FROM places")) + } + @Test("Table name containing 'update' substring is NOT flagged") func tableNameSubstringNotFlagged() { #expect(!PostGISSpatialRewrite.containsSideEffectKeyword("SELECT geom FROM update_log")) From 72fe87b23d66d4657f07385e5bcc19ad38af1d1a Mon Sep 17 00:00:00 2001 From: Shubhank Saxena Date: Fri, 29 May 2026 10:37:00 -0400 Subject: [PATCH 07/10] fix(plugin-postgresql): convert PostGIS values in place instead of re-running the query --- .../LibPQPluginConnection.swift | 135 +++++---- .../PostGISSpatialRewrite.swift | 43 +++ .../PostgreSQLPluginDriver+Spatial.swift | 279 ------------------ 3 files changed, 125 insertions(+), 332 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift delete mode 100644 Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 20dac0c92..918349a5d 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -353,7 +353,7 @@ final class LibPQPluginConnection: @unchecked Sendable { case PGRES_TUPLES_OK: defer { PQclear(result) } - return try fetchResults(from: result, originalQuery: localQuery) + return try fetchResults(from: result) default: let error = getResultError(from: result) @@ -456,7 +456,7 @@ final class LibPQPluginConnection: @unchecked Sendable { case PGRES_TUPLES_OK: defer { PQclear(result) } - return try fetchResults(from: result, originalQuery: nil) + return try fetchResults(from: result) default: let error = getResultError(from: result) @@ -660,34 +660,25 @@ final class LibPQPluginConnection: @unchecked Sendable { // MARK: - Result Parsing - private func fetchResults(from result: OpaquePointer, originalQuery: String?) throws -> LibPQPluginQueryResult { + private func fetchResults(from result: OpaquePointer) throws -> LibPQPluginQueryResult { let metadata = readColumnMetadata(from: result) - - let oidMap = postgisOidMap - if !oidMap.isEmpty, let query = originalQuery { - let spatialIndices = Set(metadata.columnOids.enumerated().compactMap { idx, oid in - oidMap[oid] != nil ? idx : nil - }) - if !spatialIndices.isEmpty, - PostGISSpatialRewrite.isSafeToWrap(query: query, columns: metadata.columns) { - if let rewritten = try executeSpatialRewrite( - originalQuery: query, - columns: metadata.columns, - originalColumnOids: metadata.columnOids, - spatialIndices: spatialIndices, - oidMap: oidMap - ) { - return rewritten - } - } - } - - return try parseRows( + 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 { @@ -718,46 +709,84 @@ final class LibPQPluginConnection: @unchecked Sendable { return ColumnMetadata(columns: columns, columnOids: columnOids, columnTypeNames: columnTypeNames) } - private func executeSpatialRewrite( - originalQuery: String, - columns: [String], - originalColumnOids: [UInt32], - spatialIndices: Set, - oidMap: [UInt32: String] - ) throws -> LibPQPluginQueryResult? { - let wrappedQuery = PostGISSpatialRewrite.buildWrappedQuery( - originalQuery: originalQuery, - columns: columns, - spatialIndices: spatialIndices + 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 wrappedResult = wrappedQuery.withCString { PQexec(conn, $0) } - guard let wrappedResult, PQresultStatus(wrappedResult) == PGRES_TUPLES_OK else { - if let wrappedResult { PQclear(wrappedResult) } - logger.warning("PostGIS spatial rewrite query failed; falling back to raw hex output") - 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) } - defer { PQclear(wrappedResult) } - let wrappedMetadata = readColumnMetadata(from: wrappedResult) - let overriddenTypeNames = originalColumnOids.enumerated().map { idx, originalOid -> String in - if spatialIndices.contains(idx), let spatialName = oidMap[originalOid] { - return spatialName + guard let result, PQresultStatus(result) == PGRES_TUPLES_OK else { + if let result { PQclear(result) } + return nil + } + defer { PQclear(result) } + + let rowCount = Int(PQntuples(result)) + var converted: [PluginCellValue] = [] + converted.reserveCapacity(rowCount) + for rowIndex in 0.. 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+Spatial.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift deleted file mode 100644 index 6c027d387..000000000 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Spatial.swift +++ /dev/null @@ -1,279 +0,0 @@ -// -// PostgreSQLPluginDriver+Spatial.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, re-execute the query wrapped in a -// projection that applies ST_AsEWKT to each spatial column. -// - -import Foundation - -enum PostGISSpatialRewrite { - static let probeQuery = "SELECT oid, typname FROM pg_type WHERE typname IN ('geometry', 'geography')" - - static func quoteIdentifier(_ ident: String) -> String { - "\"\(ident.replacingOccurrences(of: "\"", with: "\"\""))\"" - } - - static func buildWrappedQuery( - originalQuery: String, - columns: [String], - spatialIndices: Set - ) -> String { - var trimmed = originalQuery.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasSuffix(";") { - trimmed = String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) - } - - let projections = columns.enumerated().map { index, name -> String in - let quoted = quoteIdentifier(name) - if spatialIndices.contains(index) { - return "ST_AsEWKT(\(quoted)) AS \(quoted)" - } - return quoted - } - - return "SELECT \(projections.joined(separator: ", ")) FROM (\(trimmed)) AS _tp_rewrite" - } - - static func isSafeToWrap(query: String, columns: [String]) -> Bool { - guard hasUniqueColumnNames(columns) else { return false } - guard startsWithSelectWithOrValues(query) else { return false } - guard !hasTopLevelStatementSeparator(query) else { return false } - return !containsSideEffectKeyword(query) - } - - static let sideEffectTokens: Set = [ - "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "COPY", - "NEXTVAL", "SETVAL", - "PG_NOTIFY", "PG_LOGICAL_EMIT_MESSAGE", - "PG_ADVISORY_LOCK", "PG_ADVISORY_LOCK_SHARED", - "PG_ADVISORY_XACT_LOCK", "PG_ADVISORY_XACT_LOCK_SHARED", - "PG_TRY_ADVISORY_LOCK", "PG_TRY_ADVISORY_LOCK_SHARED", - "PG_TRY_ADVISORY_XACT_LOCK", "PG_TRY_ADVISORY_XACT_LOCK_SHARED" - ] - - static func containsSideEffectKeyword(_ query: String) -> Bool { - var found = false - forEachUnquotedToken(in: query) { token in - if sideEffectTokens.contains(token.uppercased()) { - found = true - } - } - return found - } - - private static func forEachUnquotedToken(in query: String, _ visit: (String) -> Void) { - let chars = Array(query) - var i = 0 - var token = "" - - func flushToken() { - if !token.isEmpty { - visit(token) - token = "" - } - } - - while i < chars.count { - let c = chars[i] - - if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { - flushToken() - while i < chars.count, chars[i] != "\n" { i += 1 } - continue - } - if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { - flushToken() - i = skipBlockComment(chars, startingAt: i) - continue - } - if c == "'" { - flushToken() - i = skipSingleQuoted(chars, startingAt: i) - continue - } - if c == "\"" { - flushToken() - i = skipDoubleQuoted(chars, startingAt: i) - continue - } - if c == "$", let endOfDollar = skipDollarQuoted(chars, startingAt: i) { - flushToken() - i = endOfDollar - continue - } - - if c.isLetter || c.isNumber || c == "_" { - token.append(c) - i += 1 - continue - } - - flushToken() - i += 1 - } - flushToken() - } - - static func hasUniqueColumnNames(_ columns: [String]) -> Bool { - Set(columns).count == columns.count - } - - static func startsWithSelectWithOrValues(_ query: String) -> Bool { - let chars = Array(query) - var i = 0 - while i < chars.count { - let c = chars[i] - if c.isWhitespace { - i += 1 - continue - } - if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { - while i < chars.count, chars[i] != "\n" { i += 1 } - continue - } - if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { - i = skipBlockComment(chars, startingAt: i) - continue - } - break - } - - var ident = "" - while i < chars.count, chars[i].isLetter { - ident.append(chars[i]) - i += 1 - } - let upper = ident.uppercased() - return upper == "SELECT" || upper == "WITH" || upper == "VALUES" - } - - static func hasTopLevelStatementSeparator(_ query: String) -> Bool { - let chars = Array(query) - var i = 0 - var sawTerminator = false - while i < chars.count { - let c = chars[i] - - if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { - while i < chars.count, chars[i] != "\n" { i += 1 } - continue - } - if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { - i = skipBlockComment(chars, startingAt: i) - continue - } - if c == "'" { - i = skipSingleQuoted(chars, startingAt: i) - continue - } - if c == "\"" { - i = skipDoubleQuoted(chars, startingAt: i) - continue - } - if c == "$", let endOfDollar = skipDollarQuoted(chars, startingAt: i) { - i = endOfDollar - continue - } - - if c == ";" { - sawTerminator = true - i += 1 - continue - } - - if sawTerminator, !c.isWhitespace { - return true - } - - i += 1 - } - return false - } - - private static func skipBlockComment(_ chars: [Character], startingAt start: Int) -> Int { - var i = start + 2 - var depth = 1 - while i < chars.count, depth > 0 { - if chars[i] == "/", i + 1 < chars.count, chars[i + 1] == "*" { - depth += 1 - i += 2 - } else if chars[i] == "*", i + 1 < chars.count, chars[i + 1] == "/" { - depth -= 1 - i += 2 - } else { - i += 1 - } - } - return i - } - - private static func skipSingleQuoted(_ chars: [Character], startingAt start: Int) -> Int { - var i = start + 1 - while i < chars.count { - if chars[i] == "'" { - if i + 1 < chars.count, chars[i + 1] == "'" { - i += 2 - } else { - return i + 1 - } - } else { - i += 1 - } - } - return i - } - - private static func skipDoubleQuoted(_ chars: [Character], startingAt start: Int) -> Int { - var i = start + 1 - while i < chars.count { - if chars[i] == "\"" { - if i + 1 < chars.count, chars[i + 1] == "\"" { - i += 2 - } else { - return i + 1 - } - } else { - i += 1 - } - } - return i - } - - private static func skipDollarQuoted(_ chars: [Character], startingAt start: Int) -> Int? { - var tagEnd = start + 1 - while tagEnd < chars.count { - let ch = chars[tagEnd] - if ch == "$" { break } - let isFirst = tagEnd == start + 1 - let validFirst = ch.isLetter || ch == "_" - let validRest = validFirst || ch.isNumber - if isFirst ? !validFirst : !validRest { - return nil - } - tagEnd += 1 - } - guard tagEnd < chars.count, chars[tagEnd] == "$" else { return nil } - - let tag = Array(chars[start...tagEnd]) - var i = tagEnd + 1 - while i + tag.count <= chars.count { - if chars[i] == "$" { - var matches = true - for j in 0.. Date: Fri, 29 May 2026 10:37:00 -0400 Subject: [PATCH 08/10] fix(plugin-postgresql): re-probe PostGIS catalogs on reconnect --- Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift | 4 ++++ Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index 540916cd3..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() { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index b26141148..dfa3cd330 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -46,9 +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() - await probePostgisOids() } private func probeCatalogPresence() async { From 90f1a8f160888aca07436c7eadc7c89f23e77b03 Mon Sep 17 00:00:00 2001 From: Shubhank Saxena Date: Fri, 29 May 2026 10:37:00 -0400 Subject: [PATCH 09/10] test(plugin-postgresql): replace safe-to-wrap tests with value-conversion coverage --- .../PostGISSpatialRewrite.swift | 280 +---------- .../Plugins/PostGISSpatialRewriteTests.swift | 463 ++---------------- 2 files changed, 54 insertions(+), 689 deletions(-) mode change 100644 => 120000 TableProTests/PluginTestSources/PostGISSpatialRewrite.swift diff --git a/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift b/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift deleted file mode 100644 index 6c027d387..000000000 --- a/TableProTests/PluginTestSources/PostGISSpatialRewrite.swift +++ /dev/null @@ -1,279 +0,0 @@ -// -// PostgreSQLPluginDriver+Spatial.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, re-execute the query wrapped in a -// projection that applies ST_AsEWKT to each spatial column. -// - -import Foundation - -enum PostGISSpatialRewrite { - static let probeQuery = "SELECT oid, typname FROM pg_type WHERE typname IN ('geometry', 'geography')" - - static func quoteIdentifier(_ ident: String) -> String { - "\"\(ident.replacingOccurrences(of: "\"", with: "\"\""))\"" - } - - static func buildWrappedQuery( - originalQuery: String, - columns: [String], - spatialIndices: Set - ) -> String { - var trimmed = originalQuery.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasSuffix(";") { - trimmed = String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) - } - - let projections = columns.enumerated().map { index, name -> String in - let quoted = quoteIdentifier(name) - if spatialIndices.contains(index) { - return "ST_AsEWKT(\(quoted)) AS \(quoted)" - } - return quoted - } - - return "SELECT \(projections.joined(separator: ", ")) FROM (\(trimmed)) AS _tp_rewrite" - } - - static func isSafeToWrap(query: String, columns: [String]) -> Bool { - guard hasUniqueColumnNames(columns) else { return false } - guard startsWithSelectWithOrValues(query) else { return false } - guard !hasTopLevelStatementSeparator(query) else { return false } - return !containsSideEffectKeyword(query) - } - - static let sideEffectTokens: Set = [ - "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "COPY", - "NEXTVAL", "SETVAL", - "PG_NOTIFY", "PG_LOGICAL_EMIT_MESSAGE", - "PG_ADVISORY_LOCK", "PG_ADVISORY_LOCK_SHARED", - "PG_ADVISORY_XACT_LOCK", "PG_ADVISORY_XACT_LOCK_SHARED", - "PG_TRY_ADVISORY_LOCK", "PG_TRY_ADVISORY_LOCK_SHARED", - "PG_TRY_ADVISORY_XACT_LOCK", "PG_TRY_ADVISORY_XACT_LOCK_SHARED" - ] - - static func containsSideEffectKeyword(_ query: String) -> Bool { - var found = false - forEachUnquotedToken(in: query) { token in - if sideEffectTokens.contains(token.uppercased()) { - found = true - } - } - return found - } - - private static func forEachUnquotedToken(in query: String, _ visit: (String) -> Void) { - let chars = Array(query) - var i = 0 - var token = "" - - func flushToken() { - if !token.isEmpty { - visit(token) - token = "" - } - } - - while i < chars.count { - let c = chars[i] - - if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { - flushToken() - while i < chars.count, chars[i] != "\n" { i += 1 } - continue - } - if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { - flushToken() - i = skipBlockComment(chars, startingAt: i) - continue - } - if c == "'" { - flushToken() - i = skipSingleQuoted(chars, startingAt: i) - continue - } - if c == "\"" { - flushToken() - i = skipDoubleQuoted(chars, startingAt: i) - continue - } - if c == "$", let endOfDollar = skipDollarQuoted(chars, startingAt: i) { - flushToken() - i = endOfDollar - continue - } - - if c.isLetter || c.isNumber || c == "_" { - token.append(c) - i += 1 - continue - } - - flushToken() - i += 1 - } - flushToken() - } - - static func hasUniqueColumnNames(_ columns: [String]) -> Bool { - Set(columns).count == columns.count - } - - static func startsWithSelectWithOrValues(_ query: String) -> Bool { - let chars = Array(query) - var i = 0 - while i < chars.count { - let c = chars[i] - if c.isWhitespace { - i += 1 - continue - } - if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { - while i < chars.count, chars[i] != "\n" { i += 1 } - continue - } - if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { - i = skipBlockComment(chars, startingAt: i) - continue - } - break - } - - var ident = "" - while i < chars.count, chars[i].isLetter { - ident.append(chars[i]) - i += 1 - } - let upper = ident.uppercased() - return upper == "SELECT" || upper == "WITH" || upper == "VALUES" - } - - static func hasTopLevelStatementSeparator(_ query: String) -> Bool { - let chars = Array(query) - var i = 0 - var sawTerminator = false - while i < chars.count { - let c = chars[i] - - if c == "-", i + 1 < chars.count, chars[i + 1] == "-" { - while i < chars.count, chars[i] != "\n" { i += 1 } - continue - } - if c == "/", i + 1 < chars.count, chars[i + 1] == "*" { - i = skipBlockComment(chars, startingAt: i) - continue - } - if c == "'" { - i = skipSingleQuoted(chars, startingAt: i) - continue - } - if c == "\"" { - i = skipDoubleQuoted(chars, startingAt: i) - continue - } - if c == "$", let endOfDollar = skipDollarQuoted(chars, startingAt: i) { - i = endOfDollar - continue - } - - if c == ";" { - sawTerminator = true - i += 1 - continue - } - - if sawTerminator, !c.isWhitespace { - return true - } - - i += 1 - } - return false - } - - private static func skipBlockComment(_ chars: [Character], startingAt start: Int) -> Int { - var i = start + 2 - var depth = 1 - while i < chars.count, depth > 0 { - if chars[i] == "/", i + 1 < chars.count, chars[i + 1] == "*" { - depth += 1 - i += 2 - } else if chars[i] == "*", i + 1 < chars.count, chars[i + 1] == "/" { - depth -= 1 - i += 2 - } else { - i += 1 - } - } - return i - } - - private static func skipSingleQuoted(_ chars: [Character], startingAt start: Int) -> Int { - var i = start + 1 - while i < chars.count { - if chars[i] == "'" { - if i + 1 < chars.count, chars[i + 1] == "'" { - i += 2 - } else { - return i + 1 - } - } else { - i += 1 - } - } - return i - } - - private static func skipDoubleQuoted(_ chars: [Character], startingAt start: Int) -> Int { - var i = start + 1 - while i < chars.count { - if chars[i] == "\"" { - if i + 1 < chars.count, chars[i + 1] == "\"" { - i += 2 - } else { - return i + 1 - } - } else { - i += 1 - } - } - return i - } - - private static func skipDollarQuoted(_ chars: [Character], startingAt start: Int) -> Int? { - var tagEnd = start + 1 - while tagEnd < chars.count { - let ch = chars[tagEnd] - if ch == "$" { break } - let isFirst = tagEnd == start + 1 - let validFirst = ch.isLetter || ch == "_" - let validRest = validFirst || ch.isNumber - if isFirst ? !validFirst : !validRest { - return nil - } - tagEnd += 1 - } - guard tagEnd < chars.count, chars[tagEnd] == "$" else { return nil } - - let tag = Array(chars[start...tagEnd]) - var i = tagEnd + 1 - while i + tag.count <= chars.count { - if chars[i] == "$" { - var matches = true - for j in 0.. Date: Mon, 1 Jun 2026 01:58:52 +0700 Subject: [PATCH 10/10] refactor(plugin-postgresql): guard PostGIS conversion against clobbering non-text cells, fix docs --- Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift | 3 ++- docs/databases/postgresql.mdx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 918349a5d..83fadd793 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -735,7 +735,8 @@ final class LibPQPluginConnection: @unchecked Sendable { continue } - for (rowIndex, value) in converted.enumerated() where column.index < rows[rowIndex].count { + for (rowIndex, value) in converted.enumerated() + where hexValues[rowIndex] != nil && column.index < rows[rowIndex].count { rows[rowIndex][column.index] = value } } diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index e4041f2e9..051f5d9ef 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -113,7 +113,7 @@ Supports `jsonb` (formatted JSON), `array`, `uuid`, `inet` (IP), `timestamp with ### 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 and wraps the query with `ST_AsEWKT(...)` before fetching. Queries that can't be safely wrapped (multi-statement scripts, duplicate output column names, non-`SELECT`/`WITH`/`VALUES` shapes) fall back to raw hex without error. +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