From dc8c183eb4d84a30001940c53dab827642349c34 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 28 Feb 2026 03:21:29 +0000 Subject: [PATCH] Use schema-inferred naming in graphql-codegen generators --- .../codegen/src/generators/field-selector.ts | 44 +++- graphql/codegen/src/generators/mutations.ts | 33 ++- .../codegen/src/generators/naming-helpers.ts | 226 ++++++++++++++++++ graphql/codegen/src/generators/select.ts | 89 +++---- 4 files changed, 328 insertions(+), 64 deletions(-) create mode 100644 graphql/codegen/src/generators/naming-helpers.ts diff --git a/graphql/codegen/src/generators/field-selector.ts b/graphql/codegen/src/generators/field-selector.ts index b55c11f25..1f55b61a5 100644 --- a/graphql/codegen/src/generators/field-selector.ts +++ b/graphql/codegen/src/generators/field-selector.ts @@ -10,6 +10,31 @@ import type { SimpleFieldSelection, } from '../types/selection'; +const relationalFieldSetCache = new WeakMap>(); + +function getRelationalFieldSet(table: CleanTable): Set { + const cached = relationalFieldSetCache.get(table); + if (cached) return cached; + + const set = new Set(); + + for (const rel of table.relations.belongsTo) { + if (rel.fieldName) set.add(rel.fieldName); + } + for (const rel of table.relations.hasOne) { + if (rel.fieldName) set.add(rel.fieldName); + } + for (const rel of table.relations.hasMany) { + if (rel.fieldName) set.add(rel.fieldName); + } + for (const rel of table.relations.manyToMany) { + if (rel.fieldName) set.add(rel.fieldName); + } + + relationalFieldSetCache.set(table, set); + return set; +} + /** * Convert simplified field selection to QueryBuilder SelectionOptions */ @@ -210,14 +235,7 @@ export function isRelationalField( fieldName: string, table: CleanTable, ): boolean { - const { belongsTo, hasOne, hasMany, manyToMany } = table.relations; - - return ( - belongsTo.some((rel) => rel.fieldName === fieldName) || - hasOne.some((rel) => rel.fieldName === fieldName) || - hasMany.some((rel) => rel.fieldName === fieldName) || - manyToMany.some((rel) => rel.fieldName === fieldName) - ); + return getRelationalFieldSet(table).has(fieldName); } /** @@ -308,12 +326,18 @@ function getRelatedTableScalarFields( 'updatedAt', ]; + const scalarFieldSet = new Set(scalarFields); + + // Use Set for O(1) duplicate checking + const includedSet = new Set(); const included: string[] = []; + const push = (fieldName: string | undefined) => { if (!fieldName) return; - if (!scalarFields.includes(fieldName)) return; - if (included.includes(fieldName)) return; + if (!scalarFieldSet.has(fieldName)) return; + if (includedSet.has(fieldName)) return; if (included.length >= MAX_RELATED_FIELDS) return; + includedSet.add(fieldName); included.push(fieldName); }; diff --git a/graphql/codegen/src/generators/mutations.ts b/graphql/codegen/src/generators/mutations.ts index 9a91bf0f7..0086cf866 100644 --- a/graphql/codegen/src/generators/mutations.ts +++ b/graphql/codegen/src/generators/mutations.ts @@ -5,7 +5,6 @@ import * as t from 'gql-ast'; import { OperationTypeNode, print } from 'graphql'; import type { ArgumentNode, FieldNode, VariableDefinitionNode } from 'graphql'; -import { camelize } from 'inflekt'; import { TypedDocumentString } from '../client/typed-document'; import { @@ -15,6 +14,15 @@ import { import type { MutationOptions } from '../types/mutation'; import type { CleanTable } from '../types/schema'; import { isRelationalField } from './field-selector'; +import { + toCamelCaseSingular, + toCreateInputTypeName, + toCreateMutationName, + toDeleteInputTypeName, + toDeleteMutationName, + toUpdateInputTypeName, + toUpdateMutationName, +} from './naming-helpers'; /** * Generate field selections for PostGraphile mutations using custom AST logic @@ -46,15 +54,16 @@ export function buildPostGraphileCreate( Record, { input: { [key: string]: Record } } > { - const mutationName = `create${table.name}`; - const singularName = camelize(table.name, true); + const mutationName = toCreateMutationName(table.name, table); + const singularName = toCamelCaseSingular(table.name, table); + const inputTypeName = toCreateInputTypeName(table.name, table); // Create the variable definition for $input const variableDefinitions: VariableDefinitionNode[] = [ t.variableDefinition({ variable: t.variable({ name: 'input' }), type: t.nonNullType({ - type: t.namedType({ type: `Create${table.name}Input` }), + type: t.namedType({ type: inputTypeName }), }), }), ]; @@ -120,17 +129,18 @@ export function buildPostGraphileUpdate( _options: MutationOptions = {}, ): TypedDocumentString< Record, - { input: { id: string | number; patch: Record } } + { input: { id: string | number } & Record } > { - const mutationName = `update${table.name}`; - const singularName = camelize(table.name, true); + const mutationName = toUpdateMutationName(table.name, table); + const singularName = toCamelCaseSingular(table.name, table); + const inputTypeName = toUpdateInputTypeName(table.name); // Create the variable definition for $input const variableDefinitions: VariableDefinitionNode[] = [ t.variableDefinition({ variable: t.variable({ name: 'input' }), type: t.nonNullType({ - type: t.namedType({ type: `Update${table.name}Input` }), + type: t.namedType({ type: inputTypeName }), }), }), ]; @@ -182,7 +192,7 @@ export function buildPostGraphileUpdate( __ast: ast, }) as TypedDocumentString< Record, - { input: { id: string | number; patch: Record } } + { input: { id: string | number } & Record } >; } @@ -198,14 +208,15 @@ export function buildPostGraphileDelete( Record, { input: { id: string | number } } > { - const mutationName = `delete${table.name}`; + const mutationName = toDeleteMutationName(table.name, table); + const inputTypeName = toDeleteInputTypeName(table.name); // Create the variable definition for $input const variableDefinitions: VariableDefinitionNode[] = [ t.variableDefinition({ variable: t.variable({ name: 'input' }), type: t.nonNullType({ - type: t.namedType({ type: `Delete${table.name}Input` }), + type: t.namedType({ type: inputTypeName }), }), }), ]; diff --git a/graphql/codegen/src/generators/naming-helpers.ts b/graphql/codegen/src/generators/naming-helpers.ts new file mode 100644 index 000000000..5f6b5d315 --- /dev/null +++ b/graphql/codegen/src/generators/naming-helpers.ts @@ -0,0 +1,226 @@ +/** + * Server-aware naming helpers for GraphQL query/mutation generation. + * + * These functions prefer names already discovered from the GraphQL schema + * (stored on `table.query` and `table.inflection` by `infer-tables.ts`) + * and fall back to local inflection when introspection data is unavailable. + * + * Back-ported from Dashboard's `packages/data/src/query-generator.ts`. + */ +import { camelize, pluralize } from 'inflekt'; + +import type { CleanTable } from '../types/schema'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Safely normalise a server-provided inflection value. + * Returns `null` for null, undefined, or whitespace-only strings. + */ +export function normalizeInflectionValue( + value: string | null | undefined, +): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// --------------------------------------------------------------------------- +// Plural / Singular +// --------------------------------------------------------------------------- + +/** + * Convert PascalCase table name to camelCase plural for GraphQL queries. + * Prefers server-provided `table.query.all` / `table.inflection.allRows` + * when available, with guards against naive pluralisation drift and + * missing camelCase boundaries. + * + * Example: "ActionGoal" -> "actionGoals", "User" -> "users", "Person" -> "people" + */ +export function toCamelCasePlural( + tableName: string, + table?: CleanTable | null, +): string { + const singular = camelize(tableName, true); + const inflectedPlural = pluralize(singular); + const serverPluralCandidates = [ + table?.query?.all, + table?.inflection?.allRows, + ]; + + for (const candidateRaw of serverPluralCandidates) { + const candidate = normalizeInflectionValue(candidateRaw); + if (!candidate) continue; + + // Guard against known fallback drift: + // 1. Naive pluralisation: "activitys" instead of "activities" + const isNaivePlural = + candidate === `${singular}s` && candidate !== inflectedPlural; + // 2. Missing camelCase boundaries: "deliveryzones" instead of "deliveryZones" + const isMiscased = + candidate !== inflectedPlural && + candidate.toLowerCase() === inflectedPlural.toLowerCase(); + if (isNaivePlural || isMiscased) continue; + + return candidate; + } + + return inflectedPlural; +} + +/** + * Convert PascalCase table name to camelCase singular field name. + * Prefers server-provided names when available. + */ +export function toCamelCaseSingular( + tableName: string, + table?: CleanTable | null, +): string { + const localSingular = camelize(tableName, true); + + for (const candidateRaw of [ + table?.query?.one, + table?.inflection?.tableFieldName, + ]) { + const candidate = normalizeInflectionValue(candidateRaw); + if (!candidate) continue; + // Reject miscased versions: "deliveryzone" vs "deliveryZone" + if ( + candidate !== localSingular && + candidate.toLowerCase() === localSingular.toLowerCase() + ) + continue; + return candidate; + } + + return localSingular; +} + +// --------------------------------------------------------------------------- +// Mutation names +// --------------------------------------------------------------------------- + +export function toCreateMutationName( + tableName: string, + table?: CleanTable | null, +): string { + return ( + normalizeInflectionValue(table?.query?.create) ?? `create${tableName}` + ); +} + +export function toUpdateMutationName( + tableName: string, + table?: CleanTable | null, +): string { + return ( + normalizeInflectionValue(table?.query?.update) ?? `update${tableName}` + ); +} + +export function toDeleteMutationName( + tableName: string, + table?: CleanTable | null, +): string { + return ( + normalizeInflectionValue(table?.query?.delete) ?? `delete${tableName}` + ); +} + +// --------------------------------------------------------------------------- +// Input / type names +// --------------------------------------------------------------------------- + +export function toCreateInputTypeName( + tableName: string, + table?: CleanTable | null, +): string { + return ( + normalizeInflectionValue(table?.inflection?.createInputType) ?? + `Create${tableName}Input` + ); +} + +export function toUpdateInputTypeName(tableName: string): string { + return `Update${tableName}Input`; +} + +export function toDeleteInputTypeName(tableName: string): string { + return `Delete${tableName}Input`; +} + +export function toFilterTypeName( + tableName: string, + table?: CleanTable | null, +): string { + return ( + normalizeInflectionValue(table?.inflection?.filterType) ?? + `${tableName}Filter` + ); +} + +// --------------------------------------------------------------------------- +// Patch field name +// --------------------------------------------------------------------------- + +/** + * Resolve PostGraphile patch field name. + * In v5 this is typically entity-specific: e.g. "userPatch", "contactPatch". + * Prefers the value discovered from the schema (`table.query.patchFieldName` + * or `table.inflection.patchField`), falls back to `${singularName}Patch`. + */ +export function toPatchFieldName( + tableName: string, + table?: CleanTable | null, +): string { + // First check the patch field name discovered from UpdateXxxInput + const introspectedPatch = normalizeInflectionValue( + table?.query?.patchFieldName, + ); + if (introspectedPatch) return introspectedPatch; + + // Then check the inflection table + const explicitPatchField = normalizeInflectionValue( + table?.inflection?.patchField, + ); + if (explicitPatchField) return explicitPatchField; + + return `${toCamelCaseSingular(tableName, table)}Patch`; +} + +// --------------------------------------------------------------------------- +// OrderBy helpers +// --------------------------------------------------------------------------- + +/** + * Convert camelCase field name to SCREAMING_SNAKE_CASE for PostGraphile + * orderBy enums. + * + * "displayName" -> "DISPLAY_NAME_ASC" + * "createdAt" -> "CREATED_AT_DESC" + * "id" -> "ID_ASC" + */ +export function toOrderByEnumValue( + fieldName: string, + direction: 'asc' | 'desc', +): string { + const screaming = fieldName + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toUpperCase(); + return `${screaming}_${direction.toUpperCase()}`; +} + +/** + * Generate the PostGraphile OrderBy enum type name for a table. + * Prefers server-provided `table.inflection.orderByType` when available. + */ +export function toOrderByTypeName( + tableName: string, + table?: CleanTable | null, +): string { + if (table?.inflection?.orderByType) return table.inflection.orderByType; + const plural = toCamelCasePlural(tableName, table); + return `${plural.charAt(0).toUpperCase() + plural.slice(1)}OrderBy`; +} diff --git a/graphql/codegen/src/generators/select.ts b/graphql/codegen/src/generators/select.ts index d37806980..8a690dd5f 100644 --- a/graphql/codegen/src/generators/select.ts +++ b/graphql/codegen/src/generators/select.ts @@ -5,7 +5,6 @@ import * as t from 'gql-ast'; import { OperationTypeNode, print } from 'graphql'; import type { ArgumentNode, FieldNode, VariableDefinitionNode } from 'graphql'; -import { camelize, pluralize } from 'inflekt'; import { TypedDocumentString } from '../client/typed-document'; import { @@ -26,29 +25,23 @@ import type { QueryOptions } from '../types/query'; import type { CleanTable } from '../types/schema'; import type { FieldSelection } from '../types/selection'; import { convertToSelectionOptions, isRelationalField } from './field-selector'; - -/** - * Convert PascalCase table name to camelCase plural for GraphQL queries - * Uses the inflection library for proper pluralization - * Example: "ActionGoal" -> "actionGoals", "User" -> "users", "Person" -> "people" - */ -export function toCamelCasePlural(tableName: string): string { - // First convert to camelCase (lowercase first letter) - const camelCase = camelize(tableName, true); - // Then pluralize properly - return pluralize(camelCase); -} - -/** - * Generate the PostGraphile OrderBy enum type name for a table - * PostGraphile uses pluralized PascalCase: "Product" -> "ProductsOrderBy" - * Example: "Product" -> "ProductsOrderBy", "Person" -> "PeopleOrderBy" - */ -export function toOrderByTypeName(tableName: string): string { - const plural = toCamelCasePlural(tableName); // "products", "people" - // Capitalize first letter for PascalCase - return `${plural.charAt(0).toUpperCase() + plural.slice(1)}OrderBy`; -} +import { + normalizeInflectionValue, + toCamelCasePlural, + toCamelCaseSingular, + toCreateInputTypeName, + toCreateMutationName, + toDeleteInputTypeName, + toDeleteMutationName, + toFilterTypeName, + toOrderByTypeName, + toPatchFieldName, + toUpdateInputTypeName, + toUpdateMutationName, +} from './naming-helpers'; + +// Re-export naming helpers for backwards compatibility +export { toCamelCasePlural, toOrderByTypeName } from './naming-helpers'; /** * Convert CleanTable to MetaObject format for QueryBuilder @@ -114,7 +107,7 @@ export function generateIntrospectionSchema( for (const table of tables) { const modelName = table.name; - const pluralName = toCamelCasePlural(modelName); + const pluralName = toCamelCasePlural(modelName, table); // Basic field selection for the model const selection = table.fields.map((field) => field.name); @@ -128,7 +121,7 @@ export function generateIntrospectionSchema( } as QueryDefinition; // Add getOne query (by ID) - const singularName = camelize(modelName, true); + const singularName = toCamelCaseSingular(modelName, table); schema[singularName] = { qtype: 'getOne', model: modelName, @@ -136,8 +129,18 @@ export function generateIntrospectionSchema( properties: convertFieldsToProperties(table.fields), } as QueryDefinition; + // Derive entity-specific names from introspection data + const patchFieldName = toPatchFieldName(modelName, table); + const createMutationName = toCreateMutationName(modelName, table); + const updateMutationName = toUpdateMutationName(modelName, table); + const deleteMutationName = toDeleteMutationName(modelName, table); + const createInputType = toCreateInputTypeName(modelName, table); + const patchType = + normalizeInflectionValue(table.inflection?.patchType) ?? + `${modelName}Patch`; + // Add create mutation - schema[`create${modelName}`] = { + schema[createMutationName] = { qtype: 'mutation', mutationType: 'create', model: modelName, @@ -145,13 +148,13 @@ export function generateIntrospectionSchema( properties: { input: { name: 'input', - type: `Create${modelName}Input`, + type: createInputType, isNotNull: true, isArray: false, isArrayNotNull: false, properties: { - [camelize(modelName, true)]: { - name: camelize(modelName, true), + [singularName]: { + name: singularName, type: `${modelName}Input`, isNotNull: true, isArray: false, @@ -164,7 +167,7 @@ export function generateIntrospectionSchema( } as MutationDefinition; // Add update mutation - schema[`update${modelName}`] = { + schema[updateMutationName] = { qtype: 'mutation', mutationType: 'patch', model: modelName, @@ -172,14 +175,14 @@ export function generateIntrospectionSchema( properties: { input: { name: 'input', - type: `Update${modelName}Input`, + type: toUpdateInputTypeName(modelName), isNotNull: true, isArray: false, isArrayNotNull: false, properties: { - patch: { - name: 'patch', - type: `${modelName}Patch`, + [patchFieldName]: { + name: patchFieldName, + type: patchType, isNotNull: true, isArray: false, isArrayNotNull: false, @@ -191,7 +194,7 @@ export function generateIntrospectionSchema( } as MutationDefinition; // Add delete mutation - schema[`delete${modelName}`] = { + schema[deleteMutationName] = { qtype: 'mutation', mutationType: 'delete', model: modelName, @@ -199,7 +202,7 @@ export function generateIntrospectionSchema( properties: { input: { name: 'input', - type: `Delete${modelName}Input`, + type: toDeleteInputTypeName(modelName), isNotNull: true, isArray: false, isArrayNotNull: false, @@ -350,7 +353,7 @@ function generateSelectQueryAST( selection: QuerySelectionOptions | null, options: QueryOptions, ): string { - const pluralName = toCamelCasePlural(table.name); + const pluralName = toCamelCasePlural(table.name, table); // Generate field selections const fieldSelections = generateFieldSelectionsFromOptions( @@ -431,7 +434,7 @@ function generateSelectQueryAST( variableDefinitions.push( t.variableDefinition({ variable: t.variable({ name: 'filter' }), - type: t.namedType({ type: `${table.name}Filter` }), + type: t.namedType({ type: toFilterTypeName(table.name, table) }), }), ); queryArgs.push( @@ -450,7 +453,7 @@ function generateSelectQueryAST( // PostGraphile expects [ProductsOrderBy!] - list of non-null enum values type: t.listType({ type: t.nonNullType({ - type: t.namedType({ type: toOrderByTypeName(table.name) }), + type: t.namedType({ type: toOrderByTypeName(table.name, table) }), }), }), }), @@ -724,7 +727,7 @@ function findRelatedTable( * Generate FindOne query AST directly from CleanTable */ function generateFindOneQueryAST(table: CleanTable): string { - const singularName = camelize(table.name, true); + const singularName = toCamelCaseSingular(table.name, table); // Generate field selections (include all non-relational fields, including complex types) const fieldSelections = table.fields @@ -779,7 +782,7 @@ function generateFindOneQueryAST(table: CleanTable): string { * Generate Count query AST directly from CleanTable */ function generateCountQueryAST(table: CleanTable): string { - const pluralName = toCamelCasePlural(table.name); + const pluralName = toCamelCasePlural(table.name, table); const ast = t.document({ definitions: [ @@ -789,7 +792,7 @@ function generateCountQueryAST(table: CleanTable): string { variableDefinitions: [ t.variableDefinition({ variable: t.variable({ name: 'filter' }), - type: t.namedType({ type: `${table.name}Filter` }), + type: t.namedType({ type: toFilterTypeName(table.name, table) }), }), ], selectionSet: t.selectionSet({