Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -259,6 +261,7 @@ final class LibPQPluginConnection: @unchecked Sendable {
stateLock.unlock()

_cachedServerVersion = nil
_cachedServerVersionNumber = 0

if let handle {
queue.async {
Expand Down Expand Up @@ -311,6 +314,10 @@ final class LibPQPluginConnection: @unchecked Sendable {
_cachedServerVersion
}

func serverVersionNumber() -> Int32 {
_cachedServerVersionNumber
}

func currentDatabase() -> String {
database
}
Expand Down
27 changes: 27 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
36 changes: 24 additions & 12 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,19 +29,16 @@ 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
AND st.relname = c.table_name
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
Expand All @@ -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,
Expand All @@ -65,19 +80,16 @@ 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
AND st.relname = c.table_name
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
Expand Down
108 changes: 68 additions & 40 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
Loading