From 767f2373a6b0a32494b4eadb63c7c4cee3021982 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 28 Feb 2026 04:12:38 +0000 Subject: [PATCH] Add isNotNull/hasDefault to CleanField and relationFieldMap alias support --- .../src/core/introspect/infer-tables.ts | 54 ++++++++++- graphql/codegen/src/generators/select.ts | 90 ++++++++++++++++--- graphql/codegen/src/types/query.ts | 9 ++ graphql/codegen/src/types/schema.ts | 4 + 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/graphql/codegen/src/core/introspect/infer-tables.ts b/graphql/codegen/src/core/introspect/infer-tables.ts index e479a2035..97d4cab0e 100644 --- a/graphql/codegen/src/core/introspect/infer-tables.ts +++ b/graphql/codegen/src/core/introspect/infer-tables.ts @@ -23,7 +23,7 @@ import type { IntrospectionType, IntrospectionTypeRef, } from '../../types/introspection'; -import { getBaseTypeName, isList, unwrapType } from '../../types/introspection'; +import { getBaseTypeName, isList, isNonNull, unwrapType } from '../../types/introspection'; import type { CleanBelongsToRelation, CleanField, @@ -354,6 +354,14 @@ function extractEntityFields( if (!entityType.fields) return fields; + // Build a lookup of CreateXxxInput fields to infer hasDefault. + // If a field is NOT NULL on the entity but NOT required in CreateXxxInput, + // then it likely has a server-side default (serial, uuid_generate_v4, now(), etc.). + const createInputRequiredFields = buildCreateInputRequiredFieldSet( + entityType.name, + typeMap, + ); + for (const field of entityType.fields) { const baseTypeName = getBaseTypeName(field.type); if (!baseTypeName) continue; @@ -370,18 +378,62 @@ function extractEntityFields( } } + // Infer isNotNull from the NON_NULL wrapper on the entity type field + const fieldIsNotNull = isNonNull(field.type); + + // Infer hasDefault: if a field is NOT NULL on the entity but NOT required + // in CreateXxxInput, it likely has a default value. + // Also: if it's absent from CreateInput entirely, it's likely computed/generated. + let fieldHasDefault: boolean | null = null; + if (createInputRequiredFields !== null) { + if (fieldIsNotNull && !createInputRequiredFields.has(field.name)) { + fieldHasDefault = true; + } else { + fieldHasDefault = false; + } + } + // Include scalar, enum, and other non-relation fields const fieldDescription = commentsEnabled ? stripSmartComments(field.description) : undefined; fields.push({ name: field.name, ...(fieldDescription ? { description: fieldDescription } : {}), type: convertToCleanFieldType(field.type), + isNotNull: fieldIsNotNull, + hasDefault: fieldHasDefault, }); } return fields; } +/** + * Build a set of field names that are required (NON_NULL) in the CreateXxxInput type. + * Returns null if the CreateXxxInput type doesn't exist (no create mutation). + */ +function buildCreateInputRequiredFieldSet( + entityName: string, + typeMap: Map, +): Set | null { + const createInputName = `Create${entityName}Input`; + const createInput = typeMap.get(createInputName); + if (!createInput?.inputFields) return null; + + // The CreateXxxInput typically has a single field like { user: UserInput! } + // We need to look inside the actual entity input type (e.g., UserInput) + const entityInputName = `${entityName}Input`; + const entityInput = typeMap.get(entityInputName); + if (!entityInput?.inputFields) return null; + + const requiredFields = new Set(); + for (const inputField of entityInput.inputFields) { + if (isNonNull(inputField.type)) { + requiredFields.add(inputField.name); + } + } + return requiredFields; +} + /** * Check if a type name is an entity type (has a corresponding Connection) */ diff --git a/graphql/codegen/src/generators/select.ts b/graphql/codegen/src/generators/select.ts index 8a690dd5f..57b8eae00 100644 --- a/graphql/codegen/src/generators/select.ts +++ b/graphql/codegen/src/generators/select.ts @@ -3,7 +3,7 @@ * Uses AST-based approach for all query generation */ import * as t from 'gql-ast'; -import { OperationTypeNode, print } from 'graphql'; +import { Kind, OperationTypeNode, print } from 'graphql'; import type { ArgumentNode, FieldNode, VariableDefinitionNode } from 'graphql'; import { TypedDocumentString } from '../client/typed-document'; @@ -296,6 +296,7 @@ export function buildSelect( tableList, selection, options, + options.relationFieldMap, ); return new TypedDocumentString(queryString, {}) as TypedDocumentString< @@ -352,6 +353,7 @@ function generateSelectQueryAST( allTables: CleanTable[], selection: QuerySelectionOptions | null, options: QueryOptions, + relationFieldMap?: Record, ): string { const pluralName = toCamelCasePlural(table.name, table); @@ -360,6 +362,7 @@ function generateSelectQueryAST( table, allTables, selection, + relationFieldMap, ); // Build the query AST @@ -529,6 +532,7 @@ function generateFieldSelectionsFromOptions( table: CleanTable, allTables: CleanTable[], selection: QuerySelectionOptions | null, + relationFieldMap?: Record, ): FieldNode[] { const DEFAULT_NESTED_RELATION_FIRST = 20; @@ -550,6 +554,11 @@ function generateFieldSelectionsFromOptions( const fieldSelections: FieldNode[] = []; Object.entries(selection).forEach(([fieldName, fieldOptions]) => { + const resolvedField = resolveSelectionFieldName(fieldName, relationFieldMap); + if (!resolvedField) { + return; // Field mapped to null — omit it + } + if (fieldOptions === true) { // Check if this field requires subfield selection const field = table.fields.find((f) => f.name === fieldName); @@ -558,7 +567,9 @@ function generateFieldSelectionsFromOptions( fieldSelections.push(getCustomAstForCleanField(field)); } else { // Simple field selection for scalar fields - fieldSelections.push(t.field({ name: fieldName })); + fieldSelections.push( + createFieldSelectionNode(resolvedField.name, resolvedField.alias), + ); } } else if (typeof fieldOptions === 'object' && fieldOptions.select) { // Nested field selection (for relation fields) @@ -591,9 +602,10 @@ function generateFieldSelectionsFromOptions( ) { // For hasMany/manyToMany relations, wrap selections in nodes { ... } fieldSelections.push( - t.field({ - name: fieldName, - args: [ + createFieldSelectionNode( + resolvedField.name, + resolvedField.alias, + [ t.argument({ name: 'first', value: t.intValue({ @@ -601,7 +613,7 @@ function generateFieldSelectionsFromOptions( }), }), ], - selectionSet: t.selectionSet({ + t.selectionSet({ selections: [ t.field({ name: 'nodes', @@ -611,17 +623,19 @@ function generateFieldSelectionsFromOptions( }), ], }), - }), + ), ); } else { // For belongsTo/hasOne relations, use direct selection fieldSelections.push( - t.field({ - name: fieldName, - selectionSet: t.selectionSet({ + createFieldSelectionNode( + resolvedField.name, + resolvedField.alias, + undefined, + t.selectionSet({ selections: nestedSelections, }), - }), + ), ); } } @@ -630,6 +644,60 @@ function generateFieldSelectionsFromOptions( return fieldSelections; } +// --------------------------------------------------------------------------- +// Field aliasing helpers (back-ported from Dashboard query-generator.ts) +// --------------------------------------------------------------------------- + +/** + * Resolve a field name through the optional relationFieldMap. + * Returns `null` if the field should be omitted (mapped to null). + * Returns `{ name, alias? }` where alias is set when the mapped name differs. + */ +function resolveSelectionFieldName( + fieldName: string, + relationFieldMap?: Record, +): { name: string; alias?: string } | null { + if (!relationFieldMap || !(fieldName in relationFieldMap)) { + return { name: fieldName }; + } + + const mappedFieldName = relationFieldMap[fieldName]; + if (!mappedFieldName) { + return null; // mapped to null → omit + } + + if (mappedFieldName === fieldName) { + return { name: fieldName }; + } + + return { name: mappedFieldName, alias: fieldName }; +} + +/** + * Create a field AST node with optional alias support. + * When alias is provided and differs from name, a GraphQL alias is emitted: + * `alias: name { … }` instead of `name { … }` + */ +function createFieldSelectionNode( + name: string, + alias?: string, + args?: ArgumentNode[], + selectionSet?: ReturnType, +): FieldNode { + const node = t.field({ name, args, selectionSet }); + if (!alias || alias === name) { + return node; + } + + return { + ...node, + alias: { + kind: Kind.NAME, + value: alias, + }, + }; +} + /** * Get relation information for a field */ diff --git a/graphql/codegen/src/types/query.ts b/graphql/codegen/src/types/query.ts index 654735360..26db18a76 100644 --- a/graphql/codegen/src/types/query.ts +++ b/graphql/codegen/src/types/query.ts @@ -43,6 +43,15 @@ export interface QueryOptions { orderBy?: OrderByItem[]; /** Field selection options */ fieldSelection?: FieldSelection; + /** + * Maps requested relation field names to actual schema field names (or null to omit). + * When the mapped name differs from the key, a GraphQL alias is emitted so the + * consumer sees a stable field name regardless of the server-side name. + * + * Example: `{ contact: 'contactByOwnerId' }` emits `contact: contactByOwnerId { … }` + * Pass `null` to suppress a relation entirely: `{ internalNotes: null }` + */ + relationFieldMap?: Record; /** Include pageInfo in response */ includePageInfo?: boolean; } diff --git a/graphql/codegen/src/types/schema.ts b/graphql/codegen/src/types/schema.ts index c9b21d0cd..8e255dd2d 100644 --- a/graphql/codegen/src/types/schema.ts +++ b/graphql/codegen/src/types/schema.ts @@ -115,6 +115,10 @@ export interface CleanField { /** Description from PostgreSQL COMMENT (smart comments stripped) */ description?: string; type: CleanFieldType; + /** Whether the column has a NOT NULL constraint (inferred from NON_NULL wrapper on entity type field) */ + isNotNull?: boolean | null; + /** Whether the column has a DEFAULT value (inferred by comparing entity vs CreateInput field nullability) */ + hasDefault?: boolean | null; } /**