From 5feeb54aeeae947c0de16bcd522f4d0991c2bf0c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 3 Sep 2026 12:19:59 +0700 Subject: [PATCH] fix(plugin-postgresql): show composite, range and extension-typed columns by their own name instead of ENUM(...) Claude-Session: https://claude.ai/code/session_014THhVdsUjbCboxNLnQB1dR --- CHANGELOG.md | 1 + .../PostgreSQLPluginDriver+Columns.swift | 9 +++-- .../PostgreSQLSchemaQueries.swift | 6 ++- .../PostgresColumnTypeResolver.swift | 2 +- .../PostgresColumnTypeResolverTests.swift | 37 ++++++++++++++++--- 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff9378f5..6be71747a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Last line of a helper process's output lost when it exits right after writing it. +- Composite, range and extension-typed PostgreSQL columns labelled `ENUM(…)` in the structure editor. - Silent fallback order when foreign keys between the exported tables form a cycle. (#2517) - Foreign keys declared twice in a SQL export on MySQL, SQL Server, DuckDB, Snowflake, CockroachDB and Redshift, and as an unsupported `ALTER TABLE` on SQLite, libSQL and Cloudflare D1. (#2517) - Foreign keys missing from Redshift's reconstructed `CREATE TABLE`. diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift index 9c3741146..bd1e87d7b 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift @@ -92,10 +92,13 @@ extension PostgreSQLPluginDriver { var map: [String: [String]] = [:] for row in result.rows { guard let schemaName = row[safe: 0]?.asText, - let typeName = row[safe: 1]?.asText, - let label = row[safe: 2]?.asText else { continue } + let typeName = row[safe: 1]?.asText else { continue } let key = PostgresColumnTypeResolver.qualifiedName(schema: schemaName, name: typeName) - map[key, default: []].append(label) + var labels = map[key] ?? [] + if let label = row[safe: 2]?.asText { + labels.append(label) + } + map[key] = labels } return map } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 2883c927d..c82fc65d5 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -201,11 +201,15 @@ enum PostgreSQLSchemaQueries { WHERE t.typtype = 'e' """ + /// Every enum appears, with a NULL label where it has none, so the column resolver can tell + /// an enum apart from a composite, a range or an extension's base type: all four reach it as + /// `USER-DEFINED`, and only an enum has a row here. static let enumLabelQuery = """ SELECT n.nspname, t.typname, e.enumlabel FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid + LEFT JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid + WHERE t.typtype = 'e' ORDER BY n.nspname, t.typname, e.enumsortorder """ diff --git a/Plugins/TableProPluginKit/PostgresColumnTypeResolver.swift b/Plugins/TableProPluginKit/PostgresColumnTypeResolver.swift index e62f5bd8d..c9d45f357 100644 --- a/Plugins/TableProPluginKit/PostgresColumnTypeResolver.swift +++ b/Plugins/TableProPluginKit/PostgresColumnTypeResolver.swift @@ -54,7 +54,7 @@ public enum PostgresColumnTypeResolver { if upper == "USER-DEFINED" { guard let labels = enumLabelsByQualifiedName[key] else { - return Resolution(dataType: "ENUM(\(udtName))", allowedValues: nil) + return Resolution(dataType: udtName, allowedValues: nil) } return Resolution(dataType: "ENUM", allowedValues: labels) } diff --git a/TableProTests/Plugins/PostgresColumnTypeResolverTests.swift b/TableProTests/Plugins/PostgresColumnTypeResolverTests.swift index d432b7ef7..561c0b69c 100644 --- a/TableProTests/Plugins/PostgresColumnTypeResolverTests.swift +++ b/TableProTests/Plugins/PostgresColumnTypeResolverTests.swift @@ -72,11 +72,38 @@ struct PostgresColumnTypeResolverTests { #expect(resolve("ARRAY", schema: "public", udt: "_pair").allowedValues == nil) } - @Test("An enum with no catalog entry keeps the existing named fallback") - func fallsBackForUnknownEnum() { - let resolution = resolve("USER-DEFINED", schema: "app", udt: "nope") - #expect(resolution.dataType == "ENUM(nope)") - #expect(resolution.allowedValues == nil) + /// A composite, a range and an extension's base type all reach the resolver as + /// `USER-DEFINED`, exactly like an enum. Only an enum has a catalog entry, so a name without + /// one is a type of another kind and is shown as itself rather than as an enum it is not. + @Test("A user-defined type with no enum entry keeps its own name") + func keepsNameForNonEnumUserType() { + for udt in ["point3d", "floatrange", "hstore", "geometry"] { + let resolution = resolve("USER-DEFINED", schema: "app", udt: udt) + #expect(resolution.dataType == udt) + #expect(resolution.allowedValues == nil) + } + } + + @Test("An enum declared with no labels is still an enum") + func resolvesEmptyEnum() { + let resolution = PostgresColumnTypeResolver.resolve( + rawDataType: "USER-DEFINED", + udtSchema: "app", + udtName: "empty", + enumLabelsByQualifiedName: ["app.empty": []], + arrayTypesByQualifiedName: [:] + ) + #expect(resolution.dataType == "ENUM") + #expect(resolution.allowedValues?.isEmpty == true) + } + + /// The catalog read has to list an enum with no labels, or the resolver could not tell it + /// from a composite. + @Test("The enum label query lists every enum, labels or not") + func enumLabelQueryListsLabellessEnums() { + let sql = PostgreSQLSchemaQueries.enumLabelQuery + #expect(sql.contains("LEFT JOIN pg_catalog.pg_enum")) + #expect(sql.contains("WHERE t.typtype = 'e'")) } @Test("Ordinary columns pass through unchanged")