Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion graphql/codegen/src/core/introspect/infer-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, IntrospectionType>,
): Set<string> | 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<string>();
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)
*/
Expand Down
90 changes: 79 additions & 11 deletions graphql/codegen/src/generators/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -296,6 +296,7 @@ export function buildSelect(
tableList,
selection,
options,
options.relationFieldMap,
);

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

Expand All @@ -360,6 +362,7 @@ function generateSelectQueryAST(
table,
allTables,
selection,
relationFieldMap,
);

// Build the query AST
Expand Down Expand Up @@ -529,6 +532,7 @@ function generateFieldSelectionsFromOptions(
table: CleanTable,
allTables: CleanTable[],
selection: QuerySelectionOptions | null,
relationFieldMap?: Record<string, string | null>,
): FieldNode[] {
const DEFAULT_NESTED_RELATION_FIRST = 20;

Expand All @@ -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);
Expand All @@ -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)
Expand Down Expand Up @@ -591,17 +602,18 @@ 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({
value: DEFAULT_NESTED_RELATION_FIRST.toString(),
}),
}),
],
selectionSet: t.selectionSet({
t.selectionSet({
selections: [
t.field({
name: 'nodes',
Expand All @@ -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,
}),
}),
),
);
}
}
Expand All @@ -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<string, string | null>,
): { 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<typeof t.selectionSet>,
): 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
*/
Expand Down
9 changes: 9 additions & 0 deletions graphql/codegen/src/types/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | null>;
/** Include pageInfo in response */
includePageInfo?: boolean;
}
Expand Down
4 changes: 4 additions & 0 deletions graphql/codegen/src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down