Skip to content

Commit 767f237

Browse files
committed
Add isNotNull/hasDefault to CleanField and relationFieldMap alias support
1 parent 5924652 commit 767f237

4 files changed

Lines changed: 145 additions & 12 deletions

File tree

graphql/codegen/src/core/introspect/infer-tables.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type {
2323
IntrospectionType,
2424
IntrospectionTypeRef,
2525
} from '../../types/introspection';
26-
import { getBaseTypeName, isList, unwrapType } from '../../types/introspection';
26+
import { getBaseTypeName, isList, isNonNull, unwrapType } from '../../types/introspection';
2727
import type {
2828
CleanBelongsToRelation,
2929
CleanField,
@@ -354,6 +354,14 @@ function extractEntityFields(
354354

355355
if (!entityType.fields) return fields;
356356

357+
// Build a lookup of CreateXxxInput fields to infer hasDefault.
358+
// If a field is NOT NULL on the entity but NOT required in CreateXxxInput,
359+
// then it likely has a server-side default (serial, uuid_generate_v4, now(), etc.).
360+
const createInputRequiredFields = buildCreateInputRequiredFieldSet(
361+
entityType.name,
362+
typeMap,
363+
);
364+
357365
for (const field of entityType.fields) {
358366
const baseTypeName = getBaseTypeName(field.type);
359367
if (!baseTypeName) continue;
@@ -370,18 +378,62 @@ function extractEntityFields(
370378
}
371379
}
372380

381+
// Infer isNotNull from the NON_NULL wrapper on the entity type field
382+
const fieldIsNotNull = isNonNull(field.type);
383+
384+
// Infer hasDefault: if a field is NOT NULL on the entity but NOT required
385+
// in CreateXxxInput, it likely has a default value.
386+
// Also: if it's absent from CreateInput entirely, it's likely computed/generated.
387+
let fieldHasDefault: boolean | null = null;
388+
if (createInputRequiredFields !== null) {
389+
if (fieldIsNotNull && !createInputRequiredFields.has(field.name)) {
390+
fieldHasDefault = true;
391+
} else {
392+
fieldHasDefault = false;
393+
}
394+
}
395+
373396
// Include scalar, enum, and other non-relation fields
374397
const fieldDescription = commentsEnabled ? stripSmartComments(field.description) : undefined;
375398
fields.push({
376399
name: field.name,
377400
...(fieldDescription ? { description: fieldDescription } : {}),
378401
type: convertToCleanFieldType(field.type),
402+
isNotNull: fieldIsNotNull,
403+
hasDefault: fieldHasDefault,
379404
});
380405
}
381406

382407
return fields;
383408
}
384409

410+
/**
411+
* Build a set of field names that are required (NON_NULL) in the CreateXxxInput type.
412+
* Returns null if the CreateXxxInput type doesn't exist (no create mutation).
413+
*/
414+
function buildCreateInputRequiredFieldSet(
415+
entityName: string,
416+
typeMap: Map<string, IntrospectionType>,
417+
): Set<string> | null {
418+
const createInputName = `Create${entityName}Input`;
419+
const createInput = typeMap.get(createInputName);
420+
if (!createInput?.inputFields) return null;
421+
422+
// The CreateXxxInput typically has a single field like { user: UserInput! }
423+
// We need to look inside the actual entity input type (e.g., UserInput)
424+
const entityInputName = `${entityName}Input`;
425+
const entityInput = typeMap.get(entityInputName);
426+
if (!entityInput?.inputFields) return null;
427+
428+
const requiredFields = new Set<string>();
429+
for (const inputField of entityInput.inputFields) {
430+
if (isNonNull(inputField.type)) {
431+
requiredFields.add(inputField.name);
432+
}
433+
}
434+
return requiredFields;
435+
}
436+
385437
/**
386438
* Check if a type name is an entity type (has a corresponding Connection)
387439
*/

graphql/codegen/src/generators/select.ts

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Uses AST-based approach for all query generation
44
*/
55
import * as t from 'gql-ast';
6-
import { OperationTypeNode, print } from 'graphql';
6+
import { Kind, OperationTypeNode, print } from 'graphql';
77
import type { ArgumentNode, FieldNode, VariableDefinitionNode } from 'graphql';
88

99
import { TypedDocumentString } from '../client/typed-document';
@@ -296,6 +296,7 @@ export function buildSelect(
296296
tableList,
297297
selection,
298298
options,
299+
options.relationFieldMap,
299300
);
300301

301302
return new TypedDocumentString(queryString, {}) as TypedDocumentString<
@@ -352,6 +353,7 @@ function generateSelectQueryAST(
352353
allTables: CleanTable[],
353354
selection: QuerySelectionOptions | null,
354355
options: QueryOptions,
356+
relationFieldMap?: Record<string, string | null>,
355357
): string {
356358
const pluralName = toCamelCasePlural(table.name, table);
357359

@@ -360,6 +362,7 @@ function generateSelectQueryAST(
360362
table,
361363
allTables,
362364
selection,
365+
relationFieldMap,
363366
);
364367

365368
// Build the query AST
@@ -529,6 +532,7 @@ function generateFieldSelectionsFromOptions(
529532
table: CleanTable,
530533
allTables: CleanTable[],
531534
selection: QuerySelectionOptions | null,
535+
relationFieldMap?: Record<string, string | null>,
532536
): FieldNode[] {
533537
const DEFAULT_NESTED_RELATION_FIRST = 20;
534538

@@ -550,6 +554,11 @@ function generateFieldSelectionsFromOptions(
550554
const fieldSelections: FieldNode[] = [];
551555

552556
Object.entries(selection).forEach(([fieldName, fieldOptions]) => {
557+
const resolvedField = resolveSelectionFieldName(fieldName, relationFieldMap);
558+
if (!resolvedField) {
559+
return; // Field mapped to null — omit it
560+
}
561+
553562
if (fieldOptions === true) {
554563
// Check if this field requires subfield selection
555564
const field = table.fields.find((f) => f.name === fieldName);
@@ -558,7 +567,9 @@ function generateFieldSelectionsFromOptions(
558567
fieldSelections.push(getCustomAstForCleanField(field));
559568
} else {
560569
// Simple field selection for scalar fields
561-
fieldSelections.push(t.field({ name: fieldName }));
570+
fieldSelections.push(
571+
createFieldSelectionNode(resolvedField.name, resolvedField.alias),
572+
);
562573
}
563574
} else if (typeof fieldOptions === 'object' && fieldOptions.select) {
564575
// Nested field selection (for relation fields)
@@ -591,17 +602,18 @@ function generateFieldSelectionsFromOptions(
591602
) {
592603
// For hasMany/manyToMany relations, wrap selections in nodes { ... }
593604
fieldSelections.push(
594-
t.field({
595-
name: fieldName,
596-
args: [
605+
createFieldSelectionNode(
606+
resolvedField.name,
607+
resolvedField.alias,
608+
[
597609
t.argument({
598610
name: 'first',
599611
value: t.intValue({
600612
value: DEFAULT_NESTED_RELATION_FIRST.toString(),
601613
}),
602614
}),
603615
],
604-
selectionSet: t.selectionSet({
616+
t.selectionSet({
605617
selections: [
606618
t.field({
607619
name: 'nodes',
@@ -611,17 +623,19 @@ function generateFieldSelectionsFromOptions(
611623
}),
612624
],
613625
}),
614-
}),
626+
),
615627
);
616628
} else {
617629
// For belongsTo/hasOne relations, use direct selection
618630
fieldSelections.push(
619-
t.field({
620-
name: fieldName,
621-
selectionSet: t.selectionSet({
631+
createFieldSelectionNode(
632+
resolvedField.name,
633+
resolvedField.alias,
634+
undefined,
635+
t.selectionSet({
622636
selections: nestedSelections,
623637
}),
624-
}),
638+
),
625639
);
626640
}
627641
}
@@ -630,6 +644,60 @@ function generateFieldSelectionsFromOptions(
630644
return fieldSelections;
631645
}
632646

647+
// ---------------------------------------------------------------------------
648+
// Field aliasing helpers (back-ported from Dashboard query-generator.ts)
649+
// ---------------------------------------------------------------------------
650+
651+
/**
652+
* Resolve a field name through the optional relationFieldMap.
653+
* Returns `null` if the field should be omitted (mapped to null).
654+
* Returns `{ name, alias? }` where alias is set when the mapped name differs.
655+
*/
656+
function resolveSelectionFieldName(
657+
fieldName: string,
658+
relationFieldMap?: Record<string, string | null>,
659+
): { name: string; alias?: string } | null {
660+
if (!relationFieldMap || !(fieldName in relationFieldMap)) {
661+
return { name: fieldName };
662+
}
663+
664+
const mappedFieldName = relationFieldMap[fieldName];
665+
if (!mappedFieldName) {
666+
return null; // mapped to null → omit
667+
}
668+
669+
if (mappedFieldName === fieldName) {
670+
return { name: fieldName };
671+
}
672+
673+
return { name: mappedFieldName, alias: fieldName };
674+
}
675+
676+
/**
677+
* Create a field AST node with optional alias support.
678+
* When alias is provided and differs from name, a GraphQL alias is emitted:
679+
* `alias: name { … }` instead of `name { … }`
680+
*/
681+
function createFieldSelectionNode(
682+
name: string,
683+
alias?: string,
684+
args?: ArgumentNode[],
685+
selectionSet?: ReturnType<typeof t.selectionSet>,
686+
): FieldNode {
687+
const node = t.field({ name, args, selectionSet });
688+
if (!alias || alias === name) {
689+
return node;
690+
}
691+
692+
return {
693+
...node,
694+
alias: {
695+
kind: Kind.NAME,
696+
value: alias,
697+
},
698+
};
699+
}
700+
633701
/**
634702
* Get relation information for a field
635703
*/

graphql/codegen/src/types/query.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ export interface QueryOptions {
4343
orderBy?: OrderByItem[];
4444
/** Field selection options */
4545
fieldSelection?: FieldSelection;
46+
/**
47+
* Maps requested relation field names to actual schema field names (or null to omit).
48+
* When the mapped name differs from the key, a GraphQL alias is emitted so the
49+
* consumer sees a stable field name regardless of the server-side name.
50+
*
51+
* Example: `{ contact: 'contactByOwnerId' }` emits `contact: contactByOwnerId { … }`
52+
* Pass `null` to suppress a relation entirely: `{ internalNotes: null }`
53+
*/
54+
relationFieldMap?: Record<string, string | null>;
4655
/** Include pageInfo in response */
4756
includePageInfo?: boolean;
4857
}

graphql/codegen/src/types/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export interface CleanField {
115115
/** Description from PostgreSQL COMMENT (smart comments stripped) */
116116
description?: string;
117117
type: CleanFieldType;
118+
/** Whether the column has a NOT NULL constraint (inferred from NON_NULL wrapper on entity type field) */
119+
isNotNull?: boolean | null;
120+
/** Whether the column has a DEFAULT value (inferred by comparing entity vs CreateInput field nullability) */
121+
hasDefault?: boolean | null;
118122
}
119123

120124
/**

0 commit comments

Comments
 (0)