diff --git a/graphile/graphile-settings/__tests__/__snapshots__/meta-schema.test.ts.snap b/graphile/graphile-settings/__tests__/__snapshots__/meta-schema.test.ts.snap index bd772942f..7fc0818f6 100644 --- a/graphile/graphile-settings/__tests__/__snapshots__/meta-schema.test.ts.snap +++ b/graphile/graphile-settings/__tests__/__snapshots__/meta-schema.test.ts.snap @@ -33,6 +33,11 @@ exports[`MetaSchemaPlugin _meta query contract contains required selection paths "foreignKeyConstraints.refFields.name", "foreignKeyConstraints.referencedFields", "foreignKeyConstraints.referencedTable", + "i18n", + "i18n.translatableFields", + "i18n.translatableFields.name", + "i18n.translatableFields.type", + "i18n.translationTable", "indexes", "indexes.columns", "indexes.fields", @@ -49,6 +54,8 @@ exports[`MetaSchemaPlugin _meta query contract contains required selection paths "query.delete", "query.one", "query.update", + "realtime", + "realtime.subscriptionFieldName", "relations", "relations.belongsTo", "relations.belongsTo.fieldName", @@ -276,6 +283,16 @@ exports[`MetaSchemaPlugin _meta query contract has stable printed GraphQL text 1 boostRecencyDecay } } + i18n { + translationTable + translatableFields { + name + type + } + } + realtime { + subscriptionFieldName + } } } }" @@ -575,6 +592,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "referencedTable": "post", }, ], + "i18n": null, "indexes": [ { "columns": [ @@ -650,6 +668,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "one": "comment", "update": "updatecomment", }, + "realtime": null, "relations": { "belongsTo": [ { @@ -913,6 +932,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "referencedTable": "user", }, ], + "i18n": null, "indexes": [ { "columns": [ @@ -988,6 +1008,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "one": "post", "update": "updatepost", }, + "realtime": null, "relations": { "belongsTo": [ { @@ -1596,6 +1617,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "referencedTable": "tag", }, ], + "i18n": null, "indexes": [ { "columns": [ @@ -1706,6 +1728,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "one": "post_tag", "update": "updatepost_tag", }, + "realtime": null, "relations": { "belongsTo": [ { @@ -1860,6 +1883,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, ], "foreignKeyConstraints": [], + "i18n": null, "indexes": [ { "columns": [ @@ -1962,6 +1986,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "one": "tag", "update": "updatetag", }, + "realtime": null, "relations": { "belongsTo": [], "has": [ @@ -2340,6 +2365,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, ], "foreignKeyConstraints": [], + "i18n": null, "indexes": [ { "columns": [ @@ -2442,6 +2468,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "one": "user", "update": "updateuser", }, + "realtime": null, "relations": { "belongsTo": [], "has": [ diff --git a/graphile/graphile-settings/__tests__/meta-schema.test.ts b/graphile/graphile-settings/__tests__/meta-schema.test.ts index 1ae21fcaa..f8c46863a 100644 --- a/graphile/graphile-settings/__tests__/meta-schema.test.ts +++ b/graphile/graphile-settings/__tests__/meta-schema.test.ts @@ -393,6 +393,13 @@ query MetaContract { hasUnifiedSearch config { weights boostRecent boostRecencyField boostRecencyDecay } } + i18n { + translationTable + translatableFields { name type } + } + realtime { + subscriptionFieldName + } } } } @@ -469,6 +476,10 @@ const REQUIRED_META_QUERY_PATHS = [ 'search.config.boostRecencyDecay', 'fields.enumValues.name', 'fields.enumValues.values', + 'i18n.translationTable', + 'i18n.translatableFields.name', + 'i18n.translatableFields.type', + 'realtime.subscriptionFieldName', ]; function collectSelectionPaths(selections: readonly SelectionNode[], prefix = ''): string[] { @@ -2200,6 +2211,188 @@ describe('MetaSchemaPlugin', () => { }); }); + describe('i18n metadata', () => { + it('returns null i18n for tables without @i18n tag', () => { + const build = createMockBuild({ + user: { + codec: createMockCodec('user', { + id: createMockAttribute('uuid'), + name: createMockAttribute('text'), + }), + uniques: [], + relations: {}, + }, + }); + const tables = callInitHook(build); + expect(tables[0].i18n).toBeNull(); + }); + + it('detects @i18n tagged tables with translation table name', () => { + const baseCodec = { + name: 'post', + attributes: { + id: createMockAttribute('uuid'), + title: createMockAttribute('text'), + body: createMockAttribute('text'), + views: createMockAttribute('int4'), + }, + isAnonymous: false, + extensions: { + pg: { schemaName: 'app_public' }, + tags: { i18n: 'post_translations' }, + }, + }; + const translationCodec = { + name: 'postTranslations', + attributes: { + post_id: createMockAttribute('uuid'), + lang_code: createMockAttribute('text'), + title: createMockAttribute('text'), + body: createMockAttribute('text'), + }, + isAnonymous: false, + extensions: { + pg: { schemaName: 'app_public', name: 'post_translations' }, + }, + }; + const build = createMockBuild({ + post: { + codec: baseCodec, + uniques: [], + relations: {}, + }, + post_translations: { + codec: translationCodec, + uniques: [], + relations: {}, + }, + }); + const tables = callInitHook(build); + const postTable = tables.find((t: any) => t.name === 'Post'); + expect(postTable.i18n).toEqual({ + translationTable: 'post_translations', + translatableFields: [ + { name: 'title', type: 'text' }, + { name: 'body', type: 'text' }, + ], + }); + }); + + it('excludes non-text columns from translatable fields', () => { + const baseCodec = { + name: 'item', + attributes: { + id: createMockAttribute('uuid'), + label: createMockAttribute('text'), + count: createMockAttribute('int4'), + }, + isAnonymous: false, + extensions: { + pg: { schemaName: 'app_public' }, + tags: { i18n: 'item_translations' }, + }, + }; + const translationCodec = { + name: 'itemTranslations', + attributes: { + item_id: createMockAttribute('uuid'), + lang_code: createMockAttribute('text'), + label: createMockAttribute('text'), + }, + isAnonymous: false, + extensions: { + pg: { schemaName: 'app_public', name: 'item_translations' }, + }, + }; + const build = createMockBuild({ + item: { + codec: baseCodec, + uniques: [], + relations: {}, + }, + item_translations: { + codec: translationCodec, + uniques: [], + relations: {}, + }, + }); + const tables = callInitHook(build); + const itemTable = tables.find((t: any) => t.name === 'Item'); + expect(itemTable.i18n).toEqual({ + translationTable: 'item_translations', + translatableFields: [{ name: 'label', type: 'text' }], + }); + }); + }); + + describe('realtime metadata', () => { + it('returns null realtime for tables without @realtime tag', () => { + const build = createMockBuild({ + user: { + codec: createMockCodec('user', { + id: createMockAttribute('uuid'), + name: createMockAttribute('text'), + }), + uniques: [], + relations: {}, + }, + }); + const tables = callInitHook(build); + expect(tables[0].realtime).toBeNull(); + }); + + it('detects @realtime tagged tables', () => { + const realtimeCodec = { + name: 'message', + attributes: { + id: createMockAttribute('uuid'), + content: createMockAttribute('text'), + }, + isAnonymous: false, + extensions: { + pg: { schemaName: 'app_public' }, + tags: { realtime: true }, + }, + }; + const build = createMockBuild({ + message: { + codec: realtimeCodec, + uniques: [], + relations: {}, + }, + }); + const tables = callInitHook(build); + expect(tables[0].realtime).toEqual({ + subscriptionFieldName: 'onMessageChanged', + }); + }); + + it('generates correct subscription field name from table type', () => { + const realtimeCodec = { + name: 'chat_room', + attributes: { + id: createMockAttribute('uuid'), + }, + isAnonymous: false, + extensions: { + pg: { schemaName: 'app_public' }, + tags: { realtime: true }, + }, + }; + const build = createMockBuild({ + chat_room: { + codec: realtimeCodec, + uniques: [], + relations: {}, + }, + }); + const tables = callInitHook(build); + expect(tables[0].realtime).toEqual({ + subscriptionFieldName: 'onChatRoomChanged', + }); + }); + }); + describe('_meta query contract', () => { it('contains required selection paths', () => { const paths = getMetaQueryTablePaths(META_QUERY_CONTRACT).sort(); diff --git a/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts b/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts index 5a5302a6b..51355b363 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/graphql-meta-field.ts @@ -251,6 +251,32 @@ function createMetaSchemaType(): GraphQLObjectType { }), }); + const MetaI18nFieldType = new GraphQLObjectType({ + name: 'MetaI18nField', + description: 'A translatable field', + fields: () => ({ + name: { type: nn(GraphQLString), description: 'GraphQL field name' }, + type: { type: nn(GraphQLString), description: 'PostgreSQL column type (text, citext)' }, + }), + }); + + const MetaI18nType = new GraphQLObjectType({ + name: 'MetaI18n', + description: 'i18n metadata for a table with @i18n tag', + fields: () => ({ + translationTable: { type: nn(GraphQLString), description: 'Name of the translation table' }, + translatableFields: { type: nnList(MetaI18nFieldType), description: 'Fields that are translatable' }, + }), + }); + + const MetaRealtimeType = new GraphQLObjectType({ + name: 'MetaRealtime', + description: 'Realtime metadata for a table with @realtime tag', + fields: () => ({ + subscriptionFieldName: { type: nn(GraphQLString), description: 'The generated subscription field name (e.g. onPostChanged)' }, + }), + }); + const MetaTableType = new GraphQLObjectType({ name: 'MetaTable', description: 'Information about a database table', @@ -268,6 +294,8 @@ function createMetaSchemaType(): GraphQLObjectType { query: { type: nn(MetaQueryType) }, storage: { type: MetaStorageType, description: 'Storage metadata (null if not a storage table)' }, search: { type: MetaSearchType, description: 'Search metadata (null if no search configured)' }, + i18n: { type: MetaI18nType, description: 'i18n metadata (null if no @i18n tag)' }, + realtime: { type: MetaRealtimeType, description: 'Realtime metadata (null if no @realtime tag)' }, }), }); diff --git a/graphile/graphile-settings/src/plugins/meta-schema/storage-search-meta-builders.ts b/graphile/graphile-settings/src/plugins/meta-schema/storage-search-meta-builders.ts index 8894da177..3f62ff551 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/storage-search-meta-builders.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/storage-search-meta-builders.ts @@ -1,5 +1,8 @@ import type { + I18nFieldMeta, + I18nMeta, PgCodec, + RealtimeMeta, SearchColumnMeta, SearchConfigMeta, SearchMeta, @@ -104,6 +107,82 @@ export function buildSearchMeta( }; } +/** + * Detect i18n metadata from a codec's @i18n smart tag. + * The @i18n tag value is the name of the translation table. + * Translatable fields are discovered by matching text/citext columns + * between the base table and the translation table codec. + */ +export function buildI18nMeta( + codec: PgCodec, + build: unknown, + inflectAttr: (attrName: string, codec: PgCodec) => string, +): I18nMeta | null { + const tags = (codec as any).extensions?.tags; + if (!tags) return null; + + const i18nTag = tags.i18n; + if (typeof i18nTag !== 'string' || i18nTag.length === 0) return null; + + const attributes = codec.attributes; + if (!attributes) return { translationTable: i18nTag, translatableFields: [] }; + + // Discover translatable fields: text/citext columns on the base table + const allowedTypes = ['text', 'citext']; + const translatableFields: I18nFieldMeta[] = []; + + // Try to find the translation codec to get the intersection of fields + const pgRegistry = (build as any)?.input?.pgRegistry; + let translationAttrs: Set | null = null; + if (pgRegistry?.pgResources) { + for (const r of Object.values(pgRegistry.pgResources)) { + const sqlName = (r as any)?.codec?.extensions?.pg?.name ?? (r as any)?.codec?.name; + if (sqlName === i18nTag) { + const tAttrs = (r as any)?.codec?.attributes; + if (tAttrs) { + translationAttrs = new Set(Object.keys(tAttrs)); + } + break; + } + } + } + + for (const [attrName, attr] of Object.entries(attributes)) { + const pgType = (attr as any)?.codec?.name; + if (!pgType || !allowedTypes.includes(pgType)) continue; + // If we found the translation table, only include columns that exist there too + if (translationAttrs && !translationAttrs.has(attrName)) continue; + translatableFields.push({ + name: inflectAttr(attrName, codec), + type: pgType, + }); + } + + return { + translationTable: i18nTag, + translatableFields, + }; +} + +/** + * Detect realtime metadata from a codec's @realtime smart tag. + * Tables tagged with @realtime get subscription fields generated. + */ +export function buildRealtimeMeta( + codec: PgCodec, + build: unknown, +): RealtimeMeta | null { + const tags = (codec as any).extensions?.tags; + if (!tags?.realtime) return null; + + const typeName = (build as any).inflection?.tableType?.(codec); + if (!typeName) return null; + + return { + subscriptionFieldName: `on${typeName}Changed`, + }; +} + function parseSearchConfig( tags: Record, ): SearchConfigMeta | null { diff --git a/graphile/graphile-settings/src/plugins/meta-schema/table-meta-builder.ts b/graphile/graphile-settings/src/plugins/meta-schema/table-meta-builder.ts index db8f9c867..0b98fccb2 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/table-meta-builder.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/table-meta-builder.ts @@ -10,7 +10,7 @@ import { buildManyToManyRelations, buildReverseRelations, } from './relation-meta-builders'; -import { buildStorageMeta, buildSearchMeta } from './storage-search-meta-builders'; +import { buildStorageMeta, buildSearchMeta, buildI18nMeta, buildRealtimeMeta } from './storage-search-meta-builders'; import { buildFieldMeta } from './type-mappings'; import { createBuildContext, @@ -93,6 +93,8 @@ function buildTableMeta( const storage = buildStorageMeta(codec); const search = buildSearchMeta(codec, context.build, context.inflectAttr); + const i18n = buildI18nMeta(codec, context.build, context.inflectAttr); + const realtime = buildRealtimeMeta(codec, context.build); return { name: tableType, @@ -108,6 +110,8 @@ function buildTableMeta( query: buildQueryMeta(resource, uniques, tableType, context.build), storage, search, + i18n, + realtime, }; } diff --git a/graphile/graphile-settings/src/plugins/meta-schema/types.ts b/graphile/graphile-settings/src/plugins/meta-schema/types.ts index bd2838bd7..22181a917 100644 --- a/graphile/graphile-settings/src/plugins/meta-schema/types.ts +++ b/graphile/graphile-settings/src/plugins/meta-schema/types.ts @@ -12,6 +12,8 @@ export interface TableMeta { query: QueryMeta; storage: StorageMeta | null; search: SearchMeta | null; + i18n: I18nMeta | null; + realtime: RealtimeMeta | null; } export interface StorageMeta { @@ -50,6 +52,25 @@ export interface SearchConfigMeta { boostRecencyDecay: number | null; } +export interface I18nFieldMeta { + /** Inflected GraphQL field name */ + name: string; + /** PostgreSQL column type (text, citext) */ + type: string; +} + +export interface I18nMeta { + /** Name of the translation table */ + translationTable: string; + /** Fields that are translatable */ + translatableFields: I18nFieldMeta[]; +} + +export interface RealtimeMeta { + /** The generated subscription field name (e.g. onPostChanged) */ + subscriptionFieldName: string; +} + export interface EnumMeta { /** The PostgreSQL enum type name */ name: string; diff --git a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap index a9eca1e52..4e7bbdd71 100644 --- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap +++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap @@ -1441,6 +1441,12 @@ type MetaTable { """Search metadata (null if no search configured)""" search: MetaSearch + + """i18n metadata (null if no @i18n tag)""" + i18n: MetaI18n + + """Realtime metadata (null if no @realtime tag)""" + realtime: MetaRealtime } """Information about a table field/column""" @@ -1633,6 +1639,30 @@ type MetaSearchConfig { boostRecencyDecay: Float } +"""i18n metadata for a table with @i18n tag""" +type MetaI18n { + """Name of the translation table""" + translationTable: String! + + """Fields that are translatable""" + translatableFields: [MetaI18nField!]! +} + +"""A translatable field""" +type MetaI18nField { + """GraphQL field name""" + name: String! + + """PostgreSQL column type (text, citext)""" + type: String! +} + +"""Realtime metadata for a table with @realtime tag""" +type MetaRealtime { + """The generated subscription field name (e.g. onPostChanged)""" + subscriptionFieldName: String! +} + """ The root mutation type which contains root level fields which mutate data. """ diff --git a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap index 86ac19680..60996f70b 100644 --- a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap +++ b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap @@ -1459,6 +1459,30 @@ based pagination. May not be used with \`last\`.", "ofType": null, }, }, + { + "args": [], + "deprecationReason": null, + "description": "i18n metadata (null if no @i18n tag)", + "isDeprecated": false, + "name": "i18n", + "type": { + "kind": "OBJECT", + "name": "MetaI18n", + "ofType": null, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Realtime metadata (null if no @realtime tag)", + "isDeprecated": false, + "name": "realtime", + "type": { + "kind": "OBJECT", + "name": "MetaRealtime", + "ofType": null, + }, + }, ], "inputFields": null, "interfaces": [], @@ -3189,6 +3213,127 @@ based pagination. May not be used with \`last\`.", "name": "Float", "possibleTypes": null, }, + { + "description": "i18n metadata for a table with @i18n tag", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "Name of the translation table", + "isDeprecated": false, + "name": "translationTable", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Fields that are translatable", + "isDeprecated": false, + "name": "translatableFields", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MetaI18nField", + "ofType": null, + }, + }, + }, + }, + }, + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "MetaI18n", + "possibleTypes": null, + }, + { + "description": "A translatable field", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "GraphQL field name", + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "PostgreSQL column type (text, citext)", + "isDeprecated": false, + "name": "type", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "MetaI18nField", + "possibleTypes": null, + }, + { + "description": "Realtime metadata for a table with @realtime tag", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "The generated subscription field name (e.g. onPostChanged)", + "isDeprecated": false, + "name": "subscriptionFieldName", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "MetaRealtime", + "possibleTypes": null, + }, { "description": "The root mutation type which contains root level fields which mutate data.", "enumValues": null,