Skip to content

Commit 9f14077

Browse files
authored
fix(plugin-postgresql): gate version-dependent system catalogs (#1240) (#1241)
1 parent 6807f25 commit 9f14077

5 files changed

Lines changed: 130 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- PostgreSQL: connecting to servers older than 9.3 no longer fails with "relation pg_matviews does not exist"; the driver feature-gates `pg_matviews`, `pg_foreign_table`, `pg_sequences`, `array_position`, `attidentity`, `attgenerated`, and ICU locale columns behind the detected server version (#1240).
13+
1014
## [0.40.2] - 2026-05-12
1115

1216
### Added

Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
131131
private var _isConnected: Bool = false
132132
private var _isShuttingDown: Bool = false
133133
private var _cachedServerVersion: String?
134+
private var _cachedServerVersionNumber: Int32 = 0
134135
private var _isCancelled: Bool = false
135136

136137
var isConnected: Bool {
@@ -231,6 +232,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
231232

232233
let version = PQserverVersion(connection)
233234
if version > 0 {
235+
self._cachedServerVersionNumber = version
234236
let major = version / 10_000
235237
if major >= 10 {
236238
let minor = version % 10_000
@@ -259,6 +261,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
259261
stateLock.unlock()
260262

261263
_cachedServerVersion = nil
264+
_cachedServerVersionNumber = 0
262265

263266
if let handle {
264267
queue.async {
@@ -311,6 +314,10 @@ final class LibPQPluginConnection: @unchecked Sendable {
311314
_cachedServerVersion
312315
}
313316

317+
func serverVersionNumber() -> Int32 {
318+
_cachedServerVersionNumber
319+
}
320+
314321
func currentDatabase() -> String {
315322
database
316323
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// PostgreSQLCapabilities.swift
3+
// PostgreSQLDriverPlugin
4+
//
5+
6+
import Foundation
7+
8+
struct PostgreSQLCapabilities: Sendable, Equatable {
9+
let serverVersion: Int32
10+
11+
static let unknown = PostgreSQLCapabilities(serverVersion: 0)
12+
13+
var hasMaterializedViewsCatalog: Bool { serverVersion >= 90_300 }
14+
var hasForeignTablesCatalog: Bool { serverVersion >= 90_100 }
15+
var hasSequencesCatalog: Bool { serverVersion >= 90_500 }
16+
17+
var hasIdentityColumns: Bool { serverVersion >= 100_000 }
18+
var hasGeneratedColumns: Bool { serverVersion >= 120_000 }
19+
20+
var hasArrayPosition: Bool { serverVersion >= 90_500 }
21+
var hasOrderedAggregates: Bool { serverVersion >= 90_000 }
22+
23+
var hasCollationProvider: Bool { serverVersion >= 100_000 }
24+
25+
var hasDatabaseICULocale: Bool { serverVersion >= 150_000 }
26+
var hasDatabaseLocale: Bool { serverVersion >= 170_000 }
27+
}

Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ extension PostgreSQLPluginDriver {
1010
func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
1111
let safeSchema = escapeLiteralForColumns(currentSchema ?? "public")
1212
let safeTable = escapeLiteralForColumns(table)
13+
let caps = capabilities
14+
let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text"
15+
let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text"
16+
let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """
17+
LEFT JOIN pg_catalog.pg_attribute a
18+
ON a.attrelid = st.relid
19+
AND a.attname = c.column_name
20+
AND NOT a.attisdropped
21+
""" : ""
1322
let query = """
1423
SELECT
1524
c.column_name,
@@ -20,19 +29,16 @@ extension PostgreSQLPluginDriver {
2029
pgd.description,
2130
c.udt_name,
2231
CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk,
23-
a.attidentity,
24-
a.attgenerated
32+
\(identityProjection),
33+
\(generatedProjection)
2534
FROM information_schema.columns c
2635
LEFT JOIN pg_catalog.pg_statio_all_tables st
2736
ON st.schemaname = c.table_schema
2837
AND st.relname = c.table_name
2938
LEFT JOIN pg_catalog.pg_description pgd
3039
ON pgd.objoid = st.relid
3140
AND pgd.objsubid = c.ordinal_position
32-
LEFT JOIN pg_catalog.pg_attribute a
33-
ON a.attrelid = st.relid
34-
AND a.attname = c.column_name
35-
AND NOT a.attisdropped
41+
\(attributeJoin)
3642
LEFT JOIN (
3743
SELECT DISTINCT kcu.column_name
3844
FROM information_schema.table_constraints tc
@@ -54,6 +60,15 @@ extension PostgreSQLPluginDriver {
5460

5561
func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] {
5662
let safeSchema = escapeLiteralForColumns(currentSchema ?? "public")
63+
let caps = capabilities
64+
let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text"
65+
let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text"
66+
let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """
67+
LEFT JOIN pg_catalog.pg_attribute a
68+
ON a.attrelid = st.relid
69+
AND a.attname = c.column_name
70+
AND NOT a.attisdropped
71+
""" : ""
5772
let query = """
5873
SELECT
5974
c.table_name,
@@ -65,19 +80,16 @@ extension PostgreSQLPluginDriver {
6580
pgd.description,
6681
c.udt_name,
6782
CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk,
68-
a.attidentity,
69-
a.attgenerated
83+
\(identityProjection),
84+
\(generatedProjection)
7085
FROM information_schema.columns c
7186
LEFT JOIN pg_catalog.pg_statio_all_tables st
7287
ON st.schemaname = c.table_schema
7388
AND st.relname = c.table_name
7489
LEFT JOIN pg_catalog.pg_description pgd
7590
ON pgd.objoid = st.relid
7691
AND pgd.objsubid = c.ordinal_position
77-
LEFT JOIN pg_catalog.pg_attribute a
78-
ON a.attrelid = st.relid
79-
AND a.attname = c.column_name
80-
AND NOT a.attisdropped
92+
\(attributeJoin)
8193
LEFT JOIN (
8294
SELECT DISTINCT kcu.table_name, kcu.column_name
8395
FROM information_schema.table_constraints tc

Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift

Lines changed: 68 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
2121
var supportsSchemas: Bool { true }
2222
var supportsTransactions: Bool { true }
2323
var serverVersion: String? { libpqConnection?.serverVersion() }
24+
var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 }
25+
var capabilities: PostgreSQLCapabilities {
26+
PostgreSQLCapabilities(serverVersion: serverVersionNumber)
27+
}
2428
var parameterStyle: ParameterStyle { .dollar }
2529

2630
var capabilities: PluginCapabilities {
@@ -230,22 +234,39 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
230234

231235
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
232236
let schemaLiteral = escapeLiteral(schema ?? _currentSchema)
233-
let query = """
237+
let caps = capabilities
238+
239+
var unions: [String] = [
240+
"""
234241
SELECT table_name, table_type FROM information_schema.tables
235242
WHERE table_schema = '\(schemaLiteral)'
236243
AND table_type IN ('BASE TABLE', 'VIEW')
237-
UNION ALL
238-
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type
239-
FROM pg_matviews
240-
WHERE schemaname = '\(schemaLiteral)'
241-
UNION ALL
242-
SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type
243-
FROM pg_foreign_table ft
244-
JOIN pg_class c ON c.oid = ft.ftrelid
245-
JOIN pg_namespace n ON n.oid = c.relnamespace
246-
WHERE n.nspname = '\(schemaLiteral)'
247-
ORDER BY table_name
248244
"""
245+
]
246+
247+
if caps.hasMaterializedViewsCatalog {
248+
unions.append(
249+
"""
250+
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type
251+
FROM pg_matviews
252+
WHERE schemaname = '\(schemaLiteral)'
253+
"""
254+
)
255+
}
256+
257+
if caps.hasForeignTablesCatalog {
258+
unions.append(
259+
"""
260+
SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type
261+
FROM pg_foreign_table ft
262+
JOIN pg_class c ON c.oid = ft.ftrelid
263+
JOIN pg_namespace n ON n.oid = c.relnamespace
264+
WHERE n.nspname = '\(schemaLiteral)'
265+
"""
266+
)
267+
}
268+
269+
let query = unions.joined(separator: "\nUNION ALL\n") + "\nORDER BY table_name"
249270
let result = try await execute(query: query)
250271
return result.rows.compactMap { row -> PluginTableInfo? in
251272
guard let name = row[0].asText else { return nil }
@@ -263,10 +284,13 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
263284

264285

265286
func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] {
287+
let columnOrdering = capabilities.hasArrayPosition
288+
? "ORDER BY array_position(ix.indkey, a.attnum)"
289+
: "ORDER BY a.attnum"
266290
let query = """
267291
SELECT
268292
i.relname AS index_name,
269-
ARRAY_AGG(a.attname ORDER BY array_position(ix.indkey, a.attnum)) AS columns,
293+
ARRAY_AGG(a.attname \(columnOrdering)) AS columns,
270294
ix.indisunique AS is_unique,
271295
ix.indisprimary AS is_primary,
272296
am.amname AS index_type,
@@ -409,22 +433,43 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
409433
func fetchTableDDL(table: String, schema: String?) async throws -> String {
410434
let safeTable = escapeLiteral(table)
411435
let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\""
436+
let caps = capabilities
412437

413-
let columnsQuery = """
414-
SELECT
415-
quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) ||
438+
let identityClause: String = caps.hasIdentityColumns ? """
416439
CASE
417440
WHEN a.attidentity = 'a' THEN ' GENERATED ALWAYS AS IDENTITY'
418441
WHEN a.attidentity = 'd' THEN ' GENERATED BY DEFAULT AS IDENTITY'
419442
ELSE ''
420443
END ||
444+
""" : ""
445+
446+
let generatedClause: String = caps.hasGeneratedColumns ? """
421447
CASE
422448
WHEN a.attgenerated = 's' THEN ' GENERATED ALWAYS AS (' || pg_get_expr(d.adbin, d.adrelid) || ') STORED'
423449
ELSE ''
424450
END ||
451+
""" : ""
452+
453+
let defaultGuard: String
454+
switch (caps.hasIdentityColumns, caps.hasGeneratedColumns) {
455+
case (true, true):
456+
defaultGuard = "AND a.attidentity = '' AND a.attgenerated = ''"
457+
case (true, false):
458+
defaultGuard = "AND a.attidentity = ''"
459+
case (false, true):
460+
defaultGuard = "AND a.attgenerated = ''"
461+
case (false, false):
462+
defaultGuard = ""
463+
}
464+
465+
let columnsQuery = """
466+
SELECT
467+
quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) ||
468+
\(identityClause)
469+
\(generatedClause)
425470
CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END ||
426471
CASE
427-
WHEN a.atthasdef AND a.attidentity = '' AND a.attgenerated = ''
472+
WHEN a.atthasdef \(defaultGuard)
428473
THEN ' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid)
429474
ELSE ''
430475
END
@@ -627,6 +672,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
627672
}
628673

629674
func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] {
675+
guard capabilities.hasSequencesCatalog else { return [] }
630676
let safeTable = escapeLiteral(table)
631677
let query = """
632678
SELECT s.sequencename,
@@ -674,8 +720,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
674720
]
675721

676722
func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? {
677-
let majorVersion = parsedServerMajorVersion()
678-
let supportsProvider = (majorVersion ?? 0) >= 15
723+
let supportsProvider = capabilities.hasDatabaseICULocale
679724

680725
async let templateDefaultsTask = fetchTemplate1Defaults()
681726
async let collationsTask = fetchCollations()
@@ -768,8 +813,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
768813

769814
var sql = "CREATE DATABASE \"\(quotedName)\" ENCODING '\(encoding)'"
770815

771-
let majorVersion = parsedServerMajorVersion()
772-
let supportsProvider = (majorVersion ?? 0) >= 15
816+
let supportsProvider = capabilities.hasDatabaseICULocale
773817
let provider = supportsProvider ? (request.values["provider"] ?? "libc") : "libc"
774818

775819
switch provider {
@@ -851,22 +895,6 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
851895
_ = try await execute(query: "DROP DATABASE \"\(escapedName)\"")
852896
}
853897

854-
private func parsedServerMajorVersion() -> Int? {
855-
guard let raw = serverVersion else { return nil }
856-
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
857-
let scanner = Scanner(string: trimmed)
858-
scanner.charactersToBeSkipped = nil
859-
_ = scanner.scanCharacters(from: CharacterSet.decimalDigits.inverted)
860-
guard let digitRun = scanner.scanCharacters(from: .decimalDigits),
861-
let value = Int(digitRun) else {
862-
return nil
863-
}
864-
if value > 999 {
865-
return value / 10_000
866-
}
867-
return value
868-
}
869-
870898
private struct Template1Defaults {
871899
let collate: String
872900
let ctype: String
@@ -875,11 +903,11 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
875903
}
876904

877905
private func fetchTemplate1Defaults() async -> Template1Defaults? {
878-
let majorVersion = parsedServerMajorVersion() ?? 0
906+
let caps = capabilities
879907
let selectColumns: String
880-
if majorVersion >= 17 {
908+
if caps.hasDatabaseLocale {
881909
selectColumns = "datcollate, datctype, datlocprovider, datlocale"
882-
} else if majorVersion >= 15 {
910+
} else if caps.hasDatabaseICULocale {
883911
selectColumns = "datcollate, datctype, datlocprovider, daticulocale"
884912
} else {
885913
selectColumns = "datcollate, datctype, NULL, NULL"

0 commit comments

Comments
 (0)