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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Types section in the sidebar for PostgreSQL enums, composites, domains and ranges, with a definition tab and enum label editing. (#2484)
- User-defined types in the structure editor's type picker. (#2484)
- `list_types` MCP tool.
- Value picker on a foreign key cell, listing rows from the referenced table with a label beside the key. (#2511)
- Breakdown of a query's time into server, first row and transfer, behind the toolbar's duration readout. (#2503)
- Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516)
Expand Down
16 changes: 16 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,23 @@ struct PostgreSQLCapabilities: Sendable, Equatable {

static let unknown = PostgreSQLCapabilities(serverVersion: 0)

/// libpq answers 0 for a handle it has not connected. A catalog query built for that has to
/// assume a current server, or it emits the legacy projection on every server that exists.
static func assumingModernWhenUnknown(_ serverVersion: Int32) -> PostgreSQLCapabilities {
PostgreSQLCapabilities(serverVersion: serverVersion <= 0 ? Int32.max : serverVersion)
}

var hasMaterializedViewsCatalog: Bool { serverVersion >= 90_300 }
var hasRangeTypes: Bool { serverVersion >= 90_200 }
var hasJsonBuildObject: Bool { serverVersion >= 90_400 }
var hasEnumLabelPlacement: Bool { serverVersion >= 90_100 }
/// ADD VALUE IF NOT EXISTS landed in 9.3. With it an add is idempotent, which is what makes
/// the driver's one reconnect-and-resend safe for a label that already landed.
var hasEnumAddValueIfNotExists: Bool { serverVersion >= 90_300 }
/// ALTER TYPE ... RENAME VALUE landed in 10; before it a label can only be added.
var hasRenameEnumValue: Bool { serverVersion >= 100_000 }
/// Every range gained a companion multirange in 14, with a name the creator may choose.
var hasMultirangeTypes: Bool { serverVersion >= 140_000 }
var hasForeignTablesCatalog: Bool { serverVersion >= 90_100 }
var hasSequencesCatalog: Bool { serverVersion >= 90_500 }
var hasBypassRLS: Bool { serverVersion >= 90_500 }
Expand Down
167 changes: 166 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,38 @@
// PostgreSQLObjectQueries.swift
// PostgreSQLDriverPlugin
//
// Catalog SQL for routines and triggers. Pure, so it is testable without a server.
// Catalog SQL for routines, triggers and user-defined types. Pure, so it is testable without a
// server.
//

import Foundation
import TableProPluginKit

public enum PostgreSQLObjectQueries {
public static func escapeLiteral(_ value: String) -> String {
value.replacingOccurrences(of: "'", with: "''")
}

/// A literal that reads the same whatever `standard_conforming_strings` is set to. Doubling the
/// quote is enough while the value has no backslash; with one, a server running the legacy
/// setting would let `\'` swallow a doubled quote and close the literal early, so such a value
/// is written as an `E''` string, where a backslash is always an escape and is doubled here.
public static func quoteLiteral(_ value: String) -> String {
guard value.contains("\\") else { return "'\(escapeLiteral(value))'" }
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "''")
return "E'\(escaped)'"
}

public static func quoteIdentifier(_ name: String) -> String {
"\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
}

public static func qualifiedName(schema: String, name: String) -> String {
"\(quoteIdentifier(schema)).\(quoteIdentifier(name))"
}

/// `prokind` arrived in PostgreSQL 11, which is also the first release with procedures.
public static let prokindMinimumServerVersion: Int32 = 110_000

Expand Down Expand Up @@ -121,4 +143,147 @@ public enum PostgreSQLObjectQueries {
ORDER BY c.relname, t.tgname
"""
}

/// The named types a user created, with everything the CREATE statement needs.
///
/// Every table owns a composite type of its own row, so composites are kept only where the
/// backing relation is a stand-alone type (`relkind = 'c'`). Types an extension installed are
/// excluded the way extension routines are. Arrays and the multirange PostgreSQL 14 creates
/// beside every range are other `typtype`s and never match. PostgreSQL 17 records a domain's
/// NOT NULL as a constraint row too, so the constraint list keeps CHECK constraints alone and
/// NOT NULL comes from `typnotnull`. The projection order is `PostgreSQLTypeDefinition.Column`.
///
/// A listing names a schema; a reload names an oid and no schema, because a type keeps its oid
/// when it is moved. Naming neither would list the whole database, so a caller passes one.
public static func userDefinedTypeList(schema: String?, identity: String?, serverVersionNumber: Int32) -> String {
let capabilities = PostgreSQLCapabilities.assumingModernWhenUnknown(serverVersionNumber)
let schemaPredicate = schema.map { "AND n.nspname = \(quoteLiteral($0))" } ?? ""
let identityPredicate = identity.flatMap { UInt32($0) }.map { "AND t.oid = \($0)::oid" } ?? ""
let kinds = capabilities.hasRangeTypes ? "('e', 'c', 'd', 'r')" : "('e', 'c', 'd')"
let fields = capabilities.hasJsonBuildObject ? """
(SELECT json_agg(json_build_object(
'name', a.attname,
'type', pg_catalog.format_type(a.atttypid, a.atttypmod),
'collation', CASE WHEN a.attcollation <> 0 AND a.attcollation <> ty.typcollation
THEN \(collationName("a.attcollation")) END) ORDER BY a.attnum)
FROM pg_catalog.pg_attribute a
JOIN pg_catalog.pg_type ty ON ty.oid = a.atttypid
WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped)::text
""" : "NULL::text"
let constraints = capabilities.hasJsonBuildObject ? """
(SELECT json_agg(json_build_object('name', con.conname, 'definition', pg_catalog.pg_get_constraintdef(con.oid)) ORDER BY con.conname)
FROM pg_catalog.pg_constraint con
WHERE con.contypid = t.oid AND con.contype = 'c')::text
""" : "NULL::text"
let rangeJoin = capabilities.hasRangeTypes ? "LEFT JOIN pg_catalog.pg_range r ON r.rngtypid = t.oid" : ""
let rangeSubtype = capabilities.hasRangeTypes
? "CASE WHEN t.typtype = 'r' THEN pg_catalog.format_type(r.rngsubtype, NULL) END"
: "NULL::text"
let rangeCanonical = capabilities.hasRangeTypes
? "CASE WHEN t.typtype = 'r' AND r.rngcanonical <> 0 THEN r.rngcanonical::regproc::text END"
: "NULL::text"
let rangeSubtypeDiff = capabilities.hasRangeTypes
? "CASE WHEN t.typtype = 'r' AND r.rngsubdiff <> 0 THEN r.rngsubdiff::regproc::text END"
: "NULL::text"
let rangeOpclass = capabilities.hasRangeTypes ? """
CASE WHEN t.typtype = 'r' THEN
(SELECT pg_catalog.quote_ident(opn.nspname) || '.' || pg_catalog.quote_ident(opc.opcname)
FROM pg_catalog.pg_opclass opc
JOIN pg_catalog.pg_namespace opn ON opn.oid = opc.opcnamespace
WHERE opc.oid = r.rngsubopc AND NOT opc.opcdefault)
END
""" : "NULL::text"
let rangeCollation = capabilities.hasRangeTypes ? """
CASE WHEN t.typtype = 'r' AND r.rngcollation <> 0
AND r.rngcollation <> (SELECT st.typcollation FROM pg_catalog.pg_type st WHERE st.oid = r.rngsubtype)
THEN \(collationName("r.rngcollation"))
END
""" : "NULL::text"
let rangeMultirange = capabilities.hasMultirangeTypes ? """
CASE WHEN t.typtype = 'r' THEN
(SELECT pg_catalog.quote_ident(mn.nspname) || '.' || pg_catalog.quote_ident(m.typname)
FROM pg_catalog.pg_type m
JOIN pg_catalog.pg_namespace mn ON mn.oid = m.typnamespace
WHERE m.oid = r.rngmultitypid)
END
""" : "NULL::text"
return """
SELECT
t.oid::text AS identity,
t.typname AS name,
n.nspname AS schema,
t.typtype::text AS kind,
pg_catalog.pg_get_userbyid(t.typowner) AS owner,
pg_catalog.obj_description(t.oid, 'pg_type') AS comment,
(SELECT json_agg(e.enumlabel ORDER BY e.enumsortorder)
FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid)::text AS enum_labels,
\(fields) AS fields,
CASE WHEN t.typtype = 'd' THEN pg_catalog.format_type(t.typbasetype, t.typtypmod) END AS base_type,
CASE WHEN t.typtype = 'd' AND t.typcollation <> 0
AND t.typcollation <> (SELECT b.typcollation FROM pg_catalog.pg_type b WHERE b.oid = t.typbasetype)
THEN \(collationName("t.typcollation"))
END AS collation,
t.typnotnull::text AS not_null,
t.typdefault AS default_value,
\(constraints) AS constraints,
\(rangeSubtype) AS range_subtype,
\(rangeCanonical) AS range_canonical,
\(rangeSubtypeDiff) AS range_subtype_diff,
\(rangeOpclass) AS range_opclass,
\(rangeCollation) AS range_collation,
\(rangeMultirange) AS range_multirange,
pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(t.typname) AS spelling
FROM pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
LEFT JOIN pg_catalog.pg_class c ON c.oid = t.typrelid
\(rangeJoin)
WHERE t.typtype IN \(kinds)
AND (t.typtype <> 'c' OR c.relkind = 'c')
AND NOT EXISTS (
SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_type'::regclass AND d.objid = t.oid AND d.deptype = 'e'
)
\(schemaPredicate)
\(identityPredicate)
ORDER BY t.typname
"""
}

/// The qualified, quoted name of a collation oid, spelled the way a COLLATE clause takes it.
private static func collationName(_ oidExpression: String) -> String {
"""
(SELECT pg_catalog.quote_ident(cn.nspname) || '.' || pg_catalog.quote_ident(co.collname)
FROM pg_catalog.pg_collation co
JOIN pg_catalog.pg_namespace cn ON cn.oid = co.collnamespace
WHERE co.oid = \(oidExpression))
"""
}

public static func addEnumLabel(
schema: String,
name: String,
label: String,
placement: PluginEnumLabelPlacement?,
ifNotExists: Bool
) -> String {
let clause = ifNotExists ? "ADD VALUE IF NOT EXISTS" : "ADD VALUE"
var statement = "ALTER TYPE \(qualifiedName(schema: schema, name: name)) \(clause) \(quoteLiteral(label))"
if let placement {
statement += " \(placement.placesBefore ? "BEFORE" : "AFTER") \(quoteLiteral(placement.anchor))"
}
return statement
}

public static func renameEnumLabel(schema: String, name: String, from oldLabel: String, to newLabel: String) -> String {
"ALTER TYPE \(qualifiedName(schema: schema, name: name)) RENAME VALUE \(quoteLiteral(oldLabel)) TO \(quoteLiteral(newLabel))"
}

public static func createTypeTemplate(schema: String) -> String {
"""
CREATE TYPE \(qualifiedName(schema: schema, name: "type_name")) AS ENUM (
'value_1',
'value_2'
);
"""
}
}
1 change: 1 addition & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsTriggers = true
static let supportsRoutines = true
static let supportsDatabaseTriggerBrowse = true
static let supportsUserDefinedTypeBrowse = true
static let supportsTriggerEditing = true
static let structureColumnFields: [StructureColumnField] =
[.name, .type, .nullable, .defaultValue, .generated, .generationExpression, .autoIncrement, .comment]
Expand Down
89 changes: 89 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// PostgreSQLPluginDriver+Types.swift
// PostgreSQLDriverPlugin
//

import Foundation
import TableProPluginKit

extension PostgreSQLPluginDriver {
func fetchUserDefinedTypes(schema: String?) async throws -> [PluginUserDefinedTypeInfo] {
let resolvedSchema = schema ?? currentSchema ?? "public"
let query = PostgreSQLObjectQueries.userDefinedTypeList(
schema: resolvedSchema,
identity: nil,
serverVersionNumber: serverVersionNumber
)
let result = try await execute(query: query)
return result.rows
.compactMap(PostgreSQLTypeDefinition.record(from:))
.map(PostgreSQLTypeDefinition.info(from:))
}

/// An identity is the oid this driver handed out, so one that is not an oid is refused rather
/// than widened into a schema listing that would hand back whichever type sorts first. The
/// oid is looked up without a schema predicate, because a type moved to another schema keeps
/// it, and the row that comes back is checked against it before it is believed.
func fetchUserDefinedType(_ type: PluginUserDefinedTypeInfo) async throws -> PluginUserDefinedTypeInfo {
if let identity = type.identity {
guard let oid = UInt32(identity) else { throw PluginObjectSourceError.notFound(type.name) }
let query = PostgreSQLObjectQueries.userDefinedTypeList(
schema: nil,
identity: String(oid),
serverVersionNumber: serverVersionNumber
)
let result = try await execute(query: query)
let match = result.rows
.compactMap(PostgreSQLTypeDefinition.record(from:))
.first { $0.identity == String(oid) }
guard let match else { throw PluginObjectSourceError.notFound(type.name) }
return PostgreSQLTypeDefinition.info(from: match)
}
let resolvedSchema = type.schema ?? currentSchema ?? "public"
let query = PostgreSQLObjectQueries.userDefinedTypeList(
schema: resolvedSchema,
identity: nil,
serverVersionNumber: serverVersionNumber
)
let result = try await execute(query: query)
let match = result.rows
.compactMap(PostgreSQLTypeDefinition.record(from:))
.first { $0.name == type.name }
guard let match else { throw PluginObjectSourceError.notFound(type.name) }
return PostgreSQLTypeDefinition.info(from: match)
}

func createTypeTemplate(schema: String?) -> String? {
PostgreSQLObjectQueries.createTypeTemplate(schema: schema ?? currentSchema ?? "public")
}

func generateAddEnumLabelSQL(
type: PluginUserDefinedTypeInfo,
label: String,
placement: PluginEnumLabelPlacement?
) -> String? {
guard type.kind == .enumeration else { return nil }
if placement != nil, !versionedCapabilities.hasEnumLabelPlacement { return nil }
return PostgreSQLObjectQueries.addEnumLabel(
schema: type.schema ?? currentSchema ?? "public",
name: type.name,
label: label,
placement: placement,
ifNotExists: versionedCapabilities.hasEnumAddValueIfNotExists
)
}

func generateRenameEnumLabelSQL(
type: PluginUserDefinedTypeInfo,
from oldLabel: String,
to newLabel: String
) -> String? {
guard type.kind == .enumeration, versionedCapabilities.hasRenameEnumValue else { return nil }
return PostgreSQLObjectQueries.renameEnumLabel(
schema: type.schema ?? currentSchema ?? "public",
name: type.name,
from: oldLabel,
to: newLabel
)
}
}
Loading
Loading