From fa48429cd4e8c3553613c0b17f982b921ee557b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 08:28:41 +0700 Subject: [PATCH] fix(plugin-postgresql): gate version-dependent system catalogs (#1240) --- CHANGELOG.md | 4 + .../LibPQPluginConnection.swift | 7 ++ .../PostgreSQLCapabilities.swift | 27 +++++ .../PostgreSQLPluginDriver+Columns.swift | 36 ++++-- .../PostgreSQLPluginDriver.swift | 108 +++++++++++------- 5 files changed, 130 insertions(+), 52 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1971b65..a6954ecc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- 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). + ## [0.40.2] - 2026-05-12 ### Added diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 104fc4f8b..47174b89d 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -131,6 +131,7 @@ final class LibPQPluginConnection: @unchecked Sendable { private var _isConnected: Bool = false private var _isShuttingDown: Bool = false private var _cachedServerVersion: String? + private var _cachedServerVersionNumber: Int32 = 0 private var _isCancelled: Bool = false var isConnected: Bool { @@ -231,6 +232,7 @@ final class LibPQPluginConnection: @unchecked Sendable { let version = PQserverVersion(connection) if version > 0 { + self._cachedServerVersionNumber = version let major = version / 10_000 if major >= 10 { let minor = version % 10_000 @@ -259,6 +261,7 @@ final class LibPQPluginConnection: @unchecked Sendable { stateLock.unlock() _cachedServerVersion = nil + _cachedServerVersionNumber = 0 if let handle { queue.async { @@ -311,6 +314,10 @@ final class LibPQPluginConnection: @unchecked Sendable { _cachedServerVersion } + func serverVersionNumber() -> Int32 { + _cachedServerVersionNumber + } + func currentDatabase() -> String { database } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift new file mode 100644 index 000000000..597f6f910 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift @@ -0,0 +1,27 @@ +// +// PostgreSQLCapabilities.swift +// PostgreSQLDriverPlugin +// + +import Foundation + +struct PostgreSQLCapabilities: Sendable, Equatable { + let serverVersion: Int32 + + static let unknown = PostgreSQLCapabilities(serverVersion: 0) + + var hasMaterializedViewsCatalog: Bool { serverVersion >= 90_300 } + var hasForeignTablesCatalog: Bool { serverVersion >= 90_100 } + var hasSequencesCatalog: Bool { serverVersion >= 90_500 } + + var hasIdentityColumns: Bool { serverVersion >= 100_000 } + var hasGeneratedColumns: Bool { serverVersion >= 120_000 } + + var hasArrayPosition: Bool { serverVersion >= 90_500 } + var hasOrderedAggregates: Bool { serverVersion >= 90_000 } + + var hasCollationProvider: Bool { serverVersion >= 100_000 } + + var hasDatabaseICULocale: Bool { serverVersion >= 150_000 } + var hasDatabaseLocale: Bool { serverVersion >= 170_000 } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift index b156532d9..c0aef3054 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift @@ -10,6 +10,15 @@ extension PostgreSQLPluginDriver { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { let safeSchema = escapeLiteralForColumns(currentSchema ?? "public") let safeTable = escapeLiteralForColumns(table) + let caps = capabilities + let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" + let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" + let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """ + LEFT JOIN pg_catalog.pg_attribute a + ON a.attrelid = st.relid + AND a.attname = c.column_name + AND NOT a.attisdropped + """ : "" let query = """ SELECT c.column_name, @@ -20,8 +29,8 @@ extension PostgreSQLPluginDriver { pgd.description, c.udt_name, CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk, - a.attidentity, - a.attgenerated + \(identityProjection), + \(generatedProjection) FROM information_schema.columns c LEFT JOIN pg_catalog.pg_statio_all_tables st ON st.schemaname = c.table_schema @@ -29,10 +38,7 @@ extension PostgreSQLPluginDriver { LEFT JOIN pg_catalog.pg_description pgd ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position - LEFT JOIN pg_catalog.pg_attribute a - ON a.attrelid = st.relid - AND a.attname = c.column_name - AND NOT a.attisdropped + \(attributeJoin) LEFT JOIN ( SELECT DISTINCT kcu.column_name FROM information_schema.table_constraints tc @@ -54,6 +60,15 @@ extension PostgreSQLPluginDriver { func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let safeSchema = escapeLiteralForColumns(currentSchema ?? "public") + let caps = capabilities + let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" + let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" + let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """ + LEFT JOIN pg_catalog.pg_attribute a + ON a.attrelid = st.relid + AND a.attname = c.column_name + AND NOT a.attisdropped + """ : "" let query = """ SELECT c.table_name, @@ -65,8 +80,8 @@ extension PostgreSQLPluginDriver { pgd.description, c.udt_name, CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk, - a.attidentity, - a.attgenerated + \(identityProjection), + \(generatedProjection) FROM information_schema.columns c LEFT JOIN pg_catalog.pg_statio_all_tables st ON st.schemaname = c.table_schema @@ -74,10 +89,7 @@ extension PostgreSQLPluginDriver { LEFT JOIN pg_catalog.pg_description pgd ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position - LEFT JOIN pg_catalog.pg_attribute a - ON a.attrelid = st.relid - AND a.attname = c.column_name - AND NOT a.attisdropped + \(attributeJoin) LEFT JOIN ( SELECT DISTINCT kcu.table_name, kcu.column_name FROM information_schema.table_constraints tc diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 85f8ab4bd..7f97515b2 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -21,6 +21,10 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } var serverVersion: String? { libpqConnection?.serverVersion() } + var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 } + var capabilities: PostgreSQLCapabilities { + PostgreSQLCapabilities(serverVersion: serverVersionNumber) + } var parameterStyle: ParameterStyle { .dollar } var capabilities: PluginCapabilities { @@ -230,22 +234,39 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { let schemaLiteral = escapeLiteral(schema ?? _currentSchema) - let query = """ + let caps = capabilities + + var unions: [String] = [ + """ SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = '\(schemaLiteral)' AND table_type IN ('BASE TABLE', 'VIEW') - UNION ALL - SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type - FROM pg_matviews - WHERE schemaname = '\(schemaLiteral)' - UNION ALL - SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type - FROM pg_foreign_table ft - JOIN pg_class c ON c.oid = ft.ftrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '\(schemaLiteral)' - ORDER BY table_name """ + ] + + if caps.hasMaterializedViewsCatalog { + unions.append( + """ + SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type + FROM pg_matviews + WHERE schemaname = '\(schemaLiteral)' + """ + ) + } + + if caps.hasForeignTablesCatalog { + unions.append( + """ + SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type + FROM pg_foreign_table ft + JOIN pg_class c ON c.oid = ft.ftrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '\(schemaLiteral)' + """ + ) + } + + let query = unions.joined(separator: "\nUNION ALL\n") + "\nORDER BY table_name" let result = try await execute(query: query) return result.rows.compactMap { row -> PluginTableInfo? in guard let name = row[0].asText else { return nil } @@ -263,10 +284,13 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + let columnOrdering = capabilities.hasArrayPosition + ? "ORDER BY array_position(ix.indkey, a.attnum)" + : "ORDER BY a.attnum" let query = """ SELECT i.relname AS index_name, - ARRAY_AGG(a.attname ORDER BY array_position(ix.indkey, a.attnum)) AS columns, + ARRAY_AGG(a.attname \(columnOrdering)) AS columns, ix.indisunique AS is_unique, ix.indisprimary AS is_primary, am.amname AS index_type, @@ -409,22 +433,43 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeLiteral(table) let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\"" + let caps = capabilities - let columnsQuery = """ - SELECT - quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) || + let identityClause: String = caps.hasIdentityColumns ? """ CASE WHEN a.attidentity = 'a' THEN ' GENERATED ALWAYS AS IDENTITY' WHEN a.attidentity = 'd' THEN ' GENERATED BY DEFAULT AS IDENTITY' ELSE '' END || + """ : "" + + let generatedClause: String = caps.hasGeneratedColumns ? """ CASE WHEN a.attgenerated = 's' THEN ' GENERATED ALWAYS AS (' || pg_get_expr(d.adbin, d.adrelid) || ') STORED' ELSE '' END || + """ : "" + + let defaultGuard: String + switch (caps.hasIdentityColumns, caps.hasGeneratedColumns) { + case (true, true): + defaultGuard = "AND a.attidentity = '' AND a.attgenerated = ''" + case (true, false): + defaultGuard = "AND a.attidentity = ''" + case (false, true): + defaultGuard = "AND a.attgenerated = ''" + case (false, false): + defaultGuard = "" + } + + let columnsQuery = """ + SELECT + quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) || + \(identityClause) + \(generatedClause) CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END || CASE - WHEN a.atthasdef AND a.attidentity = '' AND a.attgenerated = '' + WHEN a.atthasdef \(defaultGuard) THEN ' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid) ELSE '' END @@ -627,6 +672,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] { + guard capabilities.hasSequencesCatalog else { return [] } let safeTable = escapeLiteral(table) let query = """ SELECT s.sequencename, @@ -674,8 +720,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ] func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { - let majorVersion = parsedServerMajorVersion() - let supportsProvider = (majorVersion ?? 0) >= 15 + let supportsProvider = capabilities.hasDatabaseICULocale async let templateDefaultsTask = fetchTemplate1Defaults() async let collationsTask = fetchCollations() @@ -768,8 +813,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var sql = "CREATE DATABASE \"\(quotedName)\" ENCODING '\(encoding)'" - let majorVersion = parsedServerMajorVersion() - let supportsProvider = (majorVersion ?? 0) >= 15 + let supportsProvider = capabilities.hasDatabaseICULocale let provider = supportsProvider ? (request.values["provider"] ?? "libc") : "libc" switch provider { @@ -851,22 +895,6 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "DROP DATABASE \"\(escapedName)\"") } - private func parsedServerMajorVersion() -> Int? { - guard let raw = serverVersion else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - let scanner = Scanner(string: trimmed) - scanner.charactersToBeSkipped = nil - _ = scanner.scanCharacters(from: CharacterSet.decimalDigits.inverted) - guard let digitRun = scanner.scanCharacters(from: .decimalDigits), - let value = Int(digitRun) else { - return nil - } - if value > 999 { - return value / 10_000 - } - return value - } - private struct Template1Defaults { let collate: String let ctype: String @@ -875,11 +903,11 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func fetchTemplate1Defaults() async -> Template1Defaults? { - let majorVersion = parsedServerMajorVersion() ?? 0 + let caps = capabilities let selectColumns: String - if majorVersion >= 17 { + if caps.hasDatabaseLocale { selectColumns = "datcollate, datctype, datlocprovider, datlocale" - } else if majorVersion >= 15 { + } else if caps.hasDatabaseICULocale { selectColumns = "datcollate, datctype, datlocprovider, daticulocale" } else { selectColumns = "datcollate, datctype, NULL, NULL"