From 70cd90b4e457f2ec59811a0e356d17b75a1b3cfb Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 24 Jul 2026 20:53:57 +1000 Subject: [PATCH] feat(columns): expose domain identity on domain-typed columns For a column whose type is a DOMAIN, the columns query resolved the column to the domain's base type and never surfaced the domain itself. Both fields that could carry the type name collapsed to the base type: - `format` used COALESCE(bt.typname, t.typname), and `bt` is joined only for domains, so the base type always won. - `data_type` used format_type(t.typbasetype, NULL) for a domain whose base type lives in pg_catalog. Downstream consumers had no way to tell a `jsonb` column apart from a column of a domain over `jsonb`, so the Studio table editor grid and schema visualizer label such columns with the base type, while the "New column" type picker shows the domain name because it reads from the types query (format_type(t.oid)). The same type rendered inconsistently across the UI. Add `domain_schema` and `domain_name`, both null unless the column's type is a domain. This follows information_schema.columns, which columns.sql.ts is adapted from: `data_type` there reports the underlying type and the domain is carried in separate domain_* columns. The addition is deliberately non-breaking. `format` still reports the base type because all four type generators feed it into resolvers that only know base types, enums, composites, tables and views; a domain name would fall through to `unknown` and silently regress generated types for every domain column. The typegen snapshots confirm domain columns still resolve to their base type (Json, number). COLUMNS_SQL is the single source for tables, views, materialized views and foreign tables, so all five endpoints pick this up. As with information_schema, arrays of domains are not covered: the domain identity there belongs to the element type. Fixes #1092 --- src/lib/sql/columns.sql.ts | 8 ++ src/lib/types.ts | 4 + test/db/00-init.sql | 14 ++- test/lib/columns.ts | 93 ++++++++++++++ test/lib/foreign-tables.ts | 12 ++ test/lib/tables.ts | 14 +++ test/lib/views.ts | 12 ++ test/server/materialized-views.ts | 6 + test/server/typegen.ts | 195 ++++++++++++++++++++++++++++++ 9 files changed, 357 insertions(+), 1 deletion(-) diff --git a/src/lib/sql/columns.sql.ts b/src/lib/sql/columns.sql.ts index d4f6b6c72..7c2d1d524 100644 --- a/src/lib/sql/columns.sql.ts +++ b/src/lib/sql/columns.sql.ts @@ -37,6 +37,14 @@ SELECT END END AS data_type, COALESCE(bt.typname, t.typname) AS format, + CASE + WHEN t.typtype = 'd' THEN nt.nspname + ELSE NULL + END AS domain_schema, + CASE + WHEN t.typtype = 'd' THEN t.typname + ELSE NULL + END AS domain_name, a.attidentity IN ('a', 'd') AS is_identity, CASE a.attidentity diff --git a/src/lib/types.ts b/src/lib/types.ts index 26b3bc782..e19ba916a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -27,6 +27,10 @@ export const postgresColumnSchema = Type.Object({ default_value: Type.Unknown(), data_type: Type.String(), format: Type.String(), + // Set only when the column's type is a DOMAIN. `data_type` and `format` keep + // reporting the domain's base type, as information_schema.columns does. + domain_schema: Type.Union([Type.String(), Type.Null()]), + domain_name: Type.Union([Type.String(), Type.Null()]), is_identity: Type.Boolean(), identity_generation: Type.Union([ Type.Literal('ALWAYS'), diff --git a/test/db/00-init.sql b/test/db/00-init.sql index c30e1f4a4..f5f73e80e 100644 --- a/test/db/00-init.sql +++ b/test/db/00-init.sql @@ -500,4 +500,16 @@ LANGUAGE SQL STABLE AS $$ SELECT interval_test_row.duration_required * 2; -$$; \ No newline at end of file +$$; + +-- Domain types, to check that column introspection preserves the domain +-- identity instead of collapsing to the base type. +CREATE DOMAIN public.text_search AS jsonb CHECK (jsonb_typeof(VALUE) = 'object'); +CREATE DOMAIN public.positive_int AS int4 NOT NULL CHECK (VALUE > 0); + +CREATE TABLE public.domain_test ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + search public.text_search, + quantity public.positive_int, + plain jsonb +); diff --git a/test/lib/columns.ts b/test/lib/columns.ts index 3fcac79fe..5a6d2464f 100644 --- a/test/lib/columns.ts +++ b/test/lib/columns.ts @@ -14,6 +14,8 @@ test('list', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "id": StringMatching /\\^\\\\d\\+\\\\\\.3\\$/, @@ -60,6 +62,8 @@ test('list from a single table', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "id": StringMatching /\\^\\\\d\\+\\\\\\.\\\\d\\+\\$/, @@ -80,6 +84,8 @@ test('list from a single table', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "id": StringMatching /\\^\\\\d\\+\\\\\\.\\\\d\\+\\$/, @@ -158,6 +164,8 @@ test('retrieve, create, update, delete', async () => { "comment": "foo", "data_type": "smallint", "default_value": "'42'::smallint", + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int2", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -189,6 +197,8 @@ test('retrieve, create, update, delete', async () => { "comment": "foo", "data_type": "smallint", "default_value": "'42'::smallint", + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int2", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -228,6 +238,8 @@ test('retrieve, create, update, delete', async () => { "comment": "bar", "data_type": "integer", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int4", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -259,6 +271,8 @@ test('retrieve, create, update, delete', async () => { "comment": "bar", "data_type": "integer", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int4", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -304,6 +318,8 @@ test('enum column with quoted name', async () => { "comment": null, "data_type": "USER-DEFINED", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [ "v", ], @@ -412,6 +428,8 @@ test('array column', async () => { "comment": null, "data_type": "ARRAY", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "_int2", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -459,6 +477,8 @@ test('column with default value', async () => { "comment": null, "data_type": "timestamp with time zone", "default_value": "now()", + "domain_name": null, + "domain_schema": null, "enums": [], "format": "timestamptz", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -537,6 +557,8 @@ test('update with name unchanged', async () => { "comment": null, "data_type": "smallint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int2", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -585,6 +607,8 @@ test('update with array types', async () => { "comment": null, "data_type": "ARRAY", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "_text", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -633,6 +657,8 @@ test('update with incompatible types', async () => { "comment": null, "data_type": "integer", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int4", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -680,6 +706,8 @@ test('update is_unique', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -714,6 +742,8 @@ test('update is_unique', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -763,6 +793,8 @@ test('alter column to type with uppercase', async () => { "comment": null, "data_type": "USER-DEFINED", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "T", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -810,6 +842,8 @@ test('enums are populated in enum array columns', async () => { "comment": null, "data_type": "ARRAY", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [ "a", ], @@ -864,6 +898,8 @@ create table public.t ( "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/, @@ -915,6 +951,8 @@ test('column with multiple checks', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "identity_generation": null, @@ -948,6 +986,8 @@ test('column with multiple unique constraints', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "identity_generation": null, @@ -1000,6 +1040,8 @@ test('column with fully-qualified type', async () => { "comment": null, "data_type": "USER-DEFINED", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "my_type", "identity_generation": null, @@ -1017,3 +1059,54 @@ test('column with fully-qualified type', async () => { await pgMeta.query(`drop table public.t; drop schema s cascade;`) }) + +test('domain columns keep the domain identity', async () => { + const res = await pgMeta.columns.list() + const columns = res.data + ?.filter(({ table }) => table === 'domain_test') + .map(({ name, data_type, format, domain_schema, domain_name, is_nullable }) => ({ + name, + data_type, + format, + domain_schema, + domain_name, + is_nullable, + })) + + expect(columns).toMatchInlineSnapshot(` + [ + { + "data_type": "bigint", + "domain_name": null, + "domain_schema": null, + "format": "int8", + "is_nullable": false, + "name": "id", + }, + { + "data_type": "jsonb", + "domain_name": "text_search", + "domain_schema": "public", + "format": "jsonb", + "is_nullable": true, + "name": "search", + }, + { + "data_type": "integer", + "domain_name": "positive_int", + "domain_schema": "public", + "format": "int4", + "is_nullable": false, + "name": "quantity", + }, + { + "data_type": "jsonb", + "domain_name": null, + "domain_schema": null, + "format": "jsonb", + "is_nullable": true, + "name": "plain", + }, + ] + `) +}) diff --git a/test/lib/foreign-tables.ts b/test/lib/foreign-tables.ts index 6be2360f1..e6dc0a1b9 100644 --- a/test/lib/foreign-tables.ts +++ b/test/lib/foreign-tables.ts @@ -29,6 +29,8 @@ test('list', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "identity_generation": null, @@ -47,6 +49,8 @@ test('list', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "identity_generation": null, @@ -65,6 +69,8 @@ test('list', async () => { "comment": null, "data_type": "USER-DEFINED", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [ "ACTIVE", "INACTIVE", @@ -112,6 +118,8 @@ test('retrieve', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "identity_generation": null, @@ -130,6 +138,8 @@ test('retrieve', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "identity_generation": null, @@ -148,6 +158,8 @@ test('retrieve', async () => { "comment": null, "data_type": "USER-DEFINED", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [ "ACTIVE", "INACTIVE", diff --git a/test/lib/tables.ts b/test/lib/tables.ts index 752b9e968..b651c720d 100644 --- a/test/lib/tables.ts +++ b/test/lib/tables.ts @@ -47,6 +47,8 @@ test('list', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "identity_generation": "BY DEFAULT", @@ -65,6 +67,8 @@ test('list', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "identity_generation": null, @@ -83,6 +87,8 @@ test('list', async () => { "comment": null, "data_type": "USER-DEFINED", "default_value": "'ACTIVE'::user_status", + "domain_name": null, + "domain_schema": null, "enums": [ "ACTIVE", "INACTIVE", @@ -104,6 +110,8 @@ test('list', async () => { "comment": null, "data_type": "numeric", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "numeric", "identity_generation": null, @@ -122,6 +130,8 @@ test('list', async () => { "comment": null, "data_type": "uuid", "default_value": "gen_random_uuid()", + "domain_name": null, + "domain_schema": null, "enums": [], "format": "uuid", "identity_generation": null, @@ -481,6 +491,8 @@ test('primary keys', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "identity_generation": null, @@ -499,6 +511,8 @@ test('primary keys', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "identity_generation": null, diff --git a/test/lib/views.ts b/test/lib/views.ts index e623e14be..120ddcdc4 100644 --- a/test/lib/views.ts +++ b/test/lib/views.ts @@ -13,6 +13,8 @@ test('list', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "id": "16424.1", @@ -33,6 +35,8 @@ test('list', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "id": "16424.2", @@ -53,6 +57,8 @@ test('list', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "id": "16424.3", @@ -110,6 +116,8 @@ test('retrieve', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "id": "16424.1", @@ -130,6 +138,8 @@ test('retrieve', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "id": "16424.2", @@ -150,6 +160,8 @@ test('retrieve', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "id": "16424.3", diff --git a/test/server/materialized-views.ts b/test/server/materialized-views.ts index 8799a6a94..b777ea77f 100644 --- a/test/server/materialized-views.ts +++ b/test/server/materialized-views.ts @@ -42,6 +42,8 @@ test('materialized views with columns', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "identity_generation": null, @@ -60,6 +62,8 @@ test('materialized views with columns', async () => { "comment": null, "data_type": "text", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "text", "identity_generation": null, @@ -78,6 +82,8 @@ test('materialized views with columns', async () => { "comment": null, "data_type": "bigint", "default_value": null, + "domain_name": null, + "domain_schema": null, "enums": [], "format": "int8", "identity_generation": null, diff --git a/test/server/typegen.ts b/test/server/typegen.ts index 50a0896bf..a47c59f63 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -31,6 +31,27 @@ test('typegen: typescript', async () => { } Relationships: [] } + domain_test: { + Row: { + id: number + plain: Json | null + quantity: number + search: Json | null + } + Insert: { + id?: number + plain?: Json | null + quantity: number + search?: Json | null + } + Update: { + id?: number + plain?: Json | null + quantity?: number + search?: Json | null + } + Relationships: [] + } empty: { Row: {} Insert: {} @@ -1231,6 +1252,27 @@ test('typegen w/ one-to-one relationships', async () => { } Relationships: [] } + domain_test: { + Row: { + id: number + plain: Json | null + quantity: number + search: Json | null + } + Insert: { + id?: number + plain?: Json | null + quantity: number + search?: Json | null + } + Update: { + id?: number + plain?: Json | null + quantity?: number + search?: Json | null + } + Relationships: [] + } empty: { Row: {} Insert: {} @@ -2456,6 +2498,27 @@ test('typegen: typescript w/ one-to-one relationships', async () => { } Relationships: [] } + domain_test: { + Row: { + id: number + plain: Json | null + quantity: number + search: Json | null + } + Insert: { + id?: number + plain?: Json | null + quantity: number + search?: Json | null + } + Update: { + id?: number + plain?: Json | null + quantity?: number + search?: Json | null + } + Relationships: [] + } empty: { Row: {} Insert: {} @@ -3686,6 +3749,27 @@ test('typegen: typescript w/ postgrestVersion', async () => { } Relationships: [] } + domain_test: { + Row: { + id: number + plain: Json | null + quantity: number + search: Json | null + } + Insert: { + id?: number + plain?: Json | null + quantity: number + search?: Json | null + } + Update: { + id?: number + plain?: Json | null + quantity?: number + search?: Json | null + } + Relationships: [] + } empty: { Row: {} Insert: {} @@ -5469,6 +5553,27 @@ test('typegen: go', async () => { Id *int64 \`json:"id"\` } + type PublicDomainTestSelect struct { + Id int64 \`json:"id"\` + Plain interface{} \`json:"plain"\` + Quantity int32 \`json:"quantity"\` + Search interface{} \`json:"search"\` + } + + type PublicDomainTestInsert struct { + Id *int64 \`json:"id"\` + Plain interface{} \`json:"plain"\` + Quantity int32 \`json:"quantity"\` + Search interface{} \`json:"search"\` + } + + type PublicDomainTestUpdate struct { + Id *int64 \`json:"id"\` + Plain interface{} \`json:"plain"\` + Quantity *int32 \`json:"quantity"\` + Search interface{} \`json:"search"\` + } + type PublicCategorySelect struct { Id int32 \`json:"id"\` Name string \`json:"name"\` @@ -5600,6 +5705,42 @@ test('typegen: swift', async () => { case name = "name" } } + internal struct DomainTestSelect: Codable, Hashable, Sendable, Identifiable { + internal let id: Int64 + internal let plain: AnyJSON? + internal let quantity: Int32 + internal let search: AnyJSON? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case plain = "plain" + case quantity = "quantity" + case search = "search" + } + } + internal struct DomainTestInsert: Codable, Hashable, Sendable, Identifiable { + internal let id: Int64? + internal let plain: AnyJSON? + internal let quantity: Int32 + internal let search: AnyJSON? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case plain = "plain" + case quantity = "quantity" + case search = "search" + } + } + internal struct DomainTestUpdate: Codable, Hashable, Sendable, Identifiable { + internal let id: Int64? + internal let plain: AnyJSON? + internal let quantity: Int32? + internal let search: AnyJSON? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case plain = "plain" + case quantity = "quantity" + case search = "search" + } + } internal struct EmptySelect: Codable, Hashable, Sendable { } internal struct EmptyInsert: Codable, Hashable, Sendable { @@ -6131,6 +6272,42 @@ test('typegen: swift w/ public access control', async () => { case name = "name" } } + public struct DomainTestSelect: Codable, Hashable, Sendable, Identifiable { + public let id: Int64 + public let plain: AnyJSON? + public let quantity: Int32 + public let search: AnyJSON? + public enum CodingKeys: String, CodingKey { + case id = "id" + case plain = "plain" + case quantity = "quantity" + case search = "search" + } + } + public struct DomainTestInsert: Codable, Hashable, Sendable, Identifiable { + public let id: Int64? + public let plain: AnyJSON? + public let quantity: Int32 + public let search: AnyJSON? + public enum CodingKeys: String, CodingKey { + case id = "id" + case plain = "plain" + case quantity = "quantity" + case search = "search" + } + } + public struct DomainTestUpdate: Codable, Hashable, Sendable, Identifiable { + public let id: Int64? + public let plain: AnyJSON? + public let quantity: Int32? + public let search: AnyJSON? + public enum CodingKeys: String, CodingKey { + case id = "id" + case plain = "plain" + case quantity = "quantity" + case search = "search" + } + } public struct EmptySelect: Codable, Hashable, Sendable { } public struct EmptyInsert: Codable, Hashable, Sendable { @@ -6814,6 +6991,24 @@ test('typegen: python', async () => { duration_required: NotRequired[Annotated[str, Field(alias="duration_required")]] id: NotRequired[Annotated[int, Field(alias="id")]] + class PublicDomainTest(BaseModel): + id: int = Field(alias="id") + plain: Optional[Json[Any]] = Field(alias="plain") + quantity: int = Field(alias="quantity") + search: Optional[Json[Any]] = Field(alias="search") + + class PublicDomainTestInsert(TypedDict): + id: NotRequired[Annotated[int, Field(alias="id")]] + plain: NotRequired[Annotated[Optional[Json[Any]], Field(alias="plain")]] + quantity: Annotated[int, Field(alias="quantity")] + search: NotRequired[Annotated[Optional[Json[Any]], Field(alias="search")]] + + class PublicDomainTestUpdate(TypedDict): + id: NotRequired[Annotated[int, Field(alias="id")]] + plain: NotRequired[Annotated[Optional[Json[Any]], Field(alias="plain")]] + quantity: NotRequired[Annotated[int, Field(alias="quantity")]] + search: NotRequired[Annotated[Optional[Json[Any]], Field(alias="search")]] + class PublicCategory(BaseModel): id: int = Field(alias="id") name: str = Field(alias="name")