diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/attachment.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/attachment.sql index 1dbb8dff4..d477b293a 100644 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/attachment.sql +++ b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/attachment.sql @@ -2,6 +2,6 @@ -- requires: schemas/public/schema BEGIN; -CREATE DOMAIN attachment AS text CHECK (VALUE ~ '^(https?)://[^\s/$.?#].[^\s]*$'); -COMMENT ON DOMAIN attachment IS E'@name pgpmInternalTypeAttachment'; +CREATE DOMAIN attachment AS text; +COMMENT ON DOMAIN attachment IS E'@name constructiveInternalTypeAttachment'; COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/email.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/email.sql index d7a215359..4aa06afee 100644 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/email.sql +++ b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/email.sql @@ -2,7 +2,7 @@ -- requires: schemas/public/schema BEGIN; -CREATE DOMAIN email AS citext CHECK (value ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'); -COMMENT ON DOMAIN email IS E'@name pgpmInternalTypeEmail'; +CREATE DOMAIN email AS citext; +COMMENT ON DOMAIN email IS E'@name constructiveInternalTypeEmail'; COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/hostname.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/hostname.sql index 97b83afb7..9505960c6 100644 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/hostname.sql +++ b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/hostname.sql @@ -2,7 +2,7 @@ -- requires: schemas/public/schema BEGIN; -CREATE DOMAIN hostname AS text CHECK (VALUE ~ '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'); -COMMENT ON DOMAIN hostname IS E'@name pgpmInternalTypeHostname'; +CREATE DOMAIN hostname AS text; +COMMENT ON DOMAIN hostname IS E'@name constructiveInternalTypeHostname'; COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/image.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/image.sql index c0cc4e043..24e2b786c 100644 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/image.sql +++ b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/image.sql @@ -2,11 +2,7 @@ -- requires: schemas/public/schema BEGIN; -CREATE DOMAIN image AS jsonb CHECK ( - value ?& ARRAY['url', 'mime'] - AND - value->>'url' ~ '^(https?)://[^\s/$.?#].[^\s]*$' -); -COMMENT ON DOMAIN image IS E'@name pgpmInternalTypeImage'; +CREATE DOMAIN image AS jsonb CHECK (value ? 'url'); +COMMENT ON DOMAIN image IS E'@name constructiveInternalTypeImage'; COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/multiple_select.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/multiple_select.sql deleted file mode 100644 index 930161cc6..000000000 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/multiple_select.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Deploy schemas/public/domains/multiple_select to pg --- requires: schemas/public/schema - -BEGIN; -CREATE DOMAIN multiple_select AS jsonb CHECK (value ?& ARRAY['value']); -COMMENT ON DOMAIN multiple_select IS E'@name pgpmInternalTypeMultipleSelect'; -COMMIT; - diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/origin.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/origin.sql index 299c9fe06..9246aac70 100644 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/origin.sql +++ b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/origin.sql @@ -2,7 +2,7 @@ -- requires: schemas/public/schema BEGIN; -CREATE DOMAIN origin AS text CHECK (VALUE = substring(VALUE from '^(https?://[^/]*)')); -COMMENT ON DOMAIN origin IS E'@name pgpmInternalTypeOrigin'; +CREATE DOMAIN origin AS text CHECK (value ~ '^https?://[^\s]+$'); +COMMENT ON DOMAIN origin IS E'@name constructiveInternalTypeOrigin'; COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/single_select.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/single_select.sql deleted file mode 100644 index 99995462f..000000000 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/single_select.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Deploy schemas/public/domains/single_select to pg - --- requires: schemas/public/schema - -BEGIN; - -CREATE DOMAIN single_select AS jsonb CHECK ( - value ?& ARRAY['value'] -); -COMMENT ON DOMAIN single_select IS E'@name pgpmInternalTypeSingleSelect'; - -COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/upload.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/upload.sql index 5c22e4a54..7a95a791c 100644 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/upload.sql +++ b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/upload.sql @@ -1,14 +1,7 @@ -- Deploy schemas/public/domains/upload to pg - -- requires: schemas/public/schema BEGIN; - -CREATE DOMAIN upload AS jsonb CHECK ( - value ?& ARRAY['url', 'mime'] - AND - value->>'url' ~ '^(https?)://[^\s/$.?#].[^\s]*$' -); -COMMENT ON DOMAIN upload IS E'@name pgpmInternalTypeUpload'; - +CREATE DOMAIN upload AS jsonb CHECK (value ? 'url' OR value ? 'id' OR value ? 'key'); +COMMENT ON DOMAIN upload IS E'@name constructiveInternalTypeUpload'; COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/url.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/url.sql index 8c7e9b0a3..5e5a94367 100644 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/url.sql +++ b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/deploy/schemas/public/domains/url.sql @@ -2,7 +2,7 @@ -- requires: schemas/public/schema BEGIN; -CREATE DOMAIN url AS text CHECK (VALUE ~ '^(https?)://[^\s/$.?#].[^\s]*$'); -COMMENT ON DOMAIN url IS E'@name pgpmInternalTypeUrl'; +CREATE DOMAIN url AS text CHECK (value ~ '^https?://[^\s]+$'); +COMMENT ON DOMAIN url IS E'@name constructiveInternalTypeUrl'; COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/pgpm.plan b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/pgpm.plan index cf700977e..c9653e3f5 100644 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/pgpm.plan +++ b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/pgpm.plan @@ -7,8 +7,6 @@ schemas/public/domains/attachment [schemas/public/schema] 2017-08-11T08:11:51Z s schemas/public/domains/email [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/email schemas/public/domains/hostname [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/hostname schemas/public/domains/image [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/image -schemas/public/domains/multiple_select [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/multiple_select schemas/public/domains/origin [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/origin -schemas/public/domains/single_select [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/single_select schemas/public/domains/upload [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/upload schemas/public/domains/url [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/url diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/revert/schemas/public/domains/multiple_select.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/revert/schemas/public/domains/multiple_select.sql deleted file mode 100644 index 3ba0b0f86..000000000 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/revert/schemas/public/domains/multiple_select.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Revert schemas/public/domains/multiple_select from pg - -BEGIN; - -DROP TYPE public.multiple_select; - -COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/revert/schemas/public/domains/single_select.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/revert/schemas/public/domains/single_select.sql deleted file mode 100644 index e08888b5c..000000000 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/revert/schemas/public/domains/single_select.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Revert schemas/public/domains/single_select from pg - -BEGIN; - -DROP TYPE public.single_select; - -COMMIT; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/verify/schemas/public/domains/multiple_select.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/verify/schemas/public/domains/multiple_select.sql deleted file mode 100644 index bd04fe72f..000000000 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/verify/schemas/public/domains/multiple_select.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Verify schemas/public/domains/multiple_select on pg - -BEGIN; - -SELECT verify_domain ('public.multiple_select'); - -ROLLBACK; diff --git a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/verify/schemas/public/domains/single_select.sql b/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/verify/schemas/public/domains/single_select.sql deleted file mode 100644 index 5f7d4c80f..000000000 --- a/__fixtures__/sqitch/simple-w-exts/extensions/@pgpm/types/verify/schemas/public/domains/single_select.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Verify schemas/public/domains/single_select on pg - -BEGIN; - -SELECT verify_domain ('public.single_select'); - -ROLLBACK; diff --git a/graphile/graphile-settings/src/plugins/index.ts b/graphile/graphile-settings/src/plugins/index.ts index b62915013..77f115423 100644 --- a/graphile/graphile-settings/src/plugins/index.ts +++ b/graphile/graphile-settings/src/plugins/index.ts @@ -61,3 +61,10 @@ export { TsvectorCodecPlugin, TsvectorCodecPreset, } from './tsvector-codec'; + +// PG type mappings for custom PostgreSQL types (email, url, etc.) +export { + PgTypeMappingsPlugin, + PgTypeMappingsPreset, +} from './pg-type-mappings'; +export type { TypeMapping } from './pg-type-mappings'; diff --git a/graphile/graphile-settings/src/plugins/pg-type-mappings.ts b/graphile/graphile-settings/src/plugins/pg-type-mappings.ts new file mode 100644 index 000000000..f3793484f --- /dev/null +++ b/graphile/graphile-settings/src/plugins/pg-type-mappings.ts @@ -0,0 +1,154 @@ +import type { GraphileConfig } from 'graphile-config'; +import { GraphQLString } from 'grafast/graphql'; +import sql from 'pg-sql2'; + +/** + * Type mapping configuration for custom PostgreSQL types. + */ +export interface TypeMapping { + /** PostgreSQL type name */ + name: string; + /** PostgreSQL schema/namespace name */ + namespaceName: string; + /** GraphQL type to map to */ + type: 'String'; +} + +/** + * Default type mappings for common custom PostgreSQL types. + * These are typically domain types or composite types that should be + * represented as simple scalars in GraphQL. + */ +const DEFAULT_MAPPINGS: TypeMapping[] = [ + { name: 'email', namespaceName: 'public', type: 'String' }, + { name: 'hostname', namespaceName: 'public', type: 'String' }, + { name: 'origin', namespaceName: 'public', type: 'String' }, + { name: 'url', namespaceName: 'public', type: 'String' }, +]; + +/** + * Plugin that maps custom PostgreSQL types to GraphQL scalar types. + * + * This is useful for domain types or composite types that should be + * represented as simple scalars (String, JSON) in the GraphQL API. + * + * For example, if you have: + * CREATE DOMAIN email AS text; + * CREATE TYPE url AS (value text); + * + * This plugin will map them to GraphQL String type instead of creating + * complex object types. + * + * The plugin handles both: + * 1. Domain types (simple aliases) - maps directly to the target scalar + * 2. Composite types - extracts the first field's value when converting from PG + */ +export const PgTypeMappingsPlugin: GraphileConfig.Plugin = { + name: 'PgTypeMappingsPlugin', + version: '1.0.0', + + gather: { + hooks: { + async pgCodecs_findPgCodec(info, event) { + if (event.pgCodec) { + return; + } + + const { pgType: type, serviceName } = event; + + // Find the namespace for this type + const namespace = await info.helpers.pgIntrospection.getNamespace( + serviceName, + type.typnamespace + ); + + if (!namespace) { + return; + } + + // Check if this type matches any of our mappings + const mapping = DEFAULT_MAPPINGS.find( + m => m.name === type.typname && m.namespaceName === namespace.nspname + ); + + if (!mapping) { + return; + } + + // Create a codec for this type + // For composite types, the fromPg function extracts the first field's value + // For domain types, it just passes through the value + event.pgCodec = { + name: type.typname, + sqlType: sql.identifier(namespace.nspname, type.typname), + fromPg: (value: unknown) => { + if (value == null) { + return null; + } + // If it's already a scalar, return it + if (typeof value !== 'object' || Array.isArray(value)) { + return value; + } + // For composite types, extract the first field's value + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length > 0) { + return obj[keys[0]]; + } + return value; + }, + toPg: (value: unknown) => value as string, + attributes: undefined, + executor: null, + extensions: { + oid: type._id, + pg: { + serviceName, + schemaName: namespace.nspname, + name: type.typname, + }, + tags: { + // Mark this as a custom mapped type + pgTypeMappings: mapping.type, + }, + }, + }; + }, + }, + }, + + schema: { + hooks: { + init(_, build) { + const { setGraphQLTypeForPgCodec } = build; + + // Map our custom codecs to GraphQL types + for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) { + const mappingType = codec.extensions?.tags?.pgTypeMappings as string | undefined; + if (mappingType) { + const gqlTypeName = GraphQLString.name; + setGraphQLTypeForPgCodec(codec, 'input', gqlTypeName); + setGraphQLTypeForPgCodec(codec, 'output', gqlTypeName); + } + } + + return _; + }, + }, + }, +}; + +/** + * Preset that includes the PG type mappings plugin. + * + * This preset maps common custom PostgreSQL types to GraphQL scalars: + * - email -> String + * - hostname -> String + * - url -> String + * - origin -> String + */ +export const PgTypeMappingsPreset: GraphileConfig.Preset = { + plugins: [PgTypeMappingsPlugin], +}; + +export default PgTypeMappingsPlugin; diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index edba0624f..e9d03a6df 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -9,6 +9,7 @@ import { EnableAllFilterColumnsPreset } from '../plugins/enable-all-filter-colum import { ManyToManyOptInPreset } from '../plugins/many-to-many-preset'; import { MetaSchemaPreset } from '../plugins/meta-schema'; import { TsvectorCodecPreset } from '../plugins/tsvector-codec'; +import { PgTypeMappingsPreset } from '../plugins/pg-type-mappings'; /** * Constructive PostGraphile v5 Preset @@ -25,6 +26,7 @@ import { TsvectorCodecPreset } from '../plugins/tsvector-codec'; * - Connection filter plugin with all columns filterable * - Many-to-many relationships (opt-in via @behavior +manyToMany) * - Meta schema plugin (_meta query for introspection of tables, fields, indexes) + * - PG type mappings (maps custom types like email, url to GraphQL scalars) * * DISABLED PLUGINS: * - PgConnectionArgFilterBackwardRelationsPlugin (relation filters bloat the API) @@ -58,6 +60,7 @@ export const ConstructivePreset: GraphileConfig.Preset = { ManyToManyOptInPreset, MetaSchemaPreset, TsvectorCodecPreset, + PgTypeMappingsPreset, ], /** * Disable relation filter plugins from postgraphile-plugin-connection-filter.