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
61 changes: 54 additions & 7 deletions graphql/codegen/src/core/codegen/orm/input-types-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,15 +842,62 @@ function buildSelectTypeLiteral(
): t.TSTypeLiteral {
const members: t.TSTypeElement[] = [];

// Add scalar fields
// Add scalar fields (and fields with arguments)
for (const field of table.fields) {
if (!isRelationField(field.name, table)) {
const prop = t.tsPropertySignature(
t.identifier(field.name),
t.tsTypeAnnotation(t.tsBooleanKeyword()),
);
prop.optional = true;
members.push(prop);
if (field.args && field.args.length > 0) {
// Field with arguments (e.g. requestUploadUrl on bucket types)
const argMembers: t.TSTypeElement[] = field.args.map((arg) => {
const tsType = typeRefToTs(arg.type);
const argProp = t.tsPropertySignature(
t.identifier(arg.name),
t.tsTypeAnnotation(parseTypeString(tsType)),
);
argProp.optional = !arg.isRequired;
return argProp;
});

const prop = t.tsPropertySignature(
t.identifier(field.name),
t.tsTypeAnnotation(
t.tsTypeLiteral([
(() => {
const argsProp = t.tsPropertySignature(
t.identifier('args'),
t.tsTypeAnnotation(t.tsTypeLiteral(argMembers)),
);
argsProp.optional = false;
return argsProp;
})(),
(() => {
const selectProp = t.tsPropertySignature(
t.identifier('select'),
t.tsTypeAnnotation(
t.tsTypeReference(
t.identifier('Record'),
t.tsTypeParameterInstantiation([
t.tsStringKeyword(),
t.tsBooleanKeyword(),
]),
),
),
);
selectProp.optional = true;
return selectProp;
})(),
]),
),
);
prop.optional = true;
members.push(prop);
} else {
const prop = t.tsPropertySignature(
t.identifier(field.name),
t.tsTypeAnnotation(t.tsBooleanKeyword()),
);
prop.optional = true;
members.push(prop);
}
}
}

Expand Down
13 changes: 11 additions & 2 deletions graphql/codegen/src/core/codegen/orm/select-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type SelectConfig<TFields extends string> = {
*/
export interface NestedSelectConfig {
select?: Record<string, boolean | NestedSelectConfig>;
args?: Record<string, unknown>;
first?: number;
last?: number;
after?: string;
Expand Down Expand Up @@ -140,15 +141,23 @@ export type InferSelectResult<TEntity, TSelect> = TSelect extends undefined
? K extends keyof TEntity
? TEntity[K]
: never
: TSelect[K] extends { select: infer NestedSelect }
: TSelect[K] extends { args: Record<string, unknown>; select: infer NestedSelect }
? K extends keyof TEntity
? NonNullable<TEntity[K]> extends ConnectionResult<infer NodeType>
? ConnectionResult<InferSelectResult<NodeType, NestedSelect>>
:
| InferSelectResult<NonNullable<TEntity[K]>, NestedSelect>
| (null extends TEntity[K] ? null : never)
: never
: K extends keyof TEntity
: TSelect[K] extends { select: infer NestedSelect }
? K extends keyof TEntity
? NonNullable<TEntity[K]> extends ConnectionResult<infer NodeType>
? ConnectionResult<InferSelectResult<NodeType, NestedSelect>>
:
| InferSelectResult<NonNullable<TEntity[K]>, NestedSelect>
| (null extends TEntity[K] ? null : never)
: never
: K extends keyof TEntity
? TEntity[K]
: never;
};
Expand Down
32 changes: 32 additions & 0 deletions graphql/codegen/src/core/codegen/templates/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,44 @@ export function buildSelections(
if (typeof value === 'object' && value !== null) {
const nested = value as {
select?: Record<string, unknown>;
args?: Record<string, unknown>;
first?: number;
filter?: Record<string, unknown>;
orderBy?: string[];
connection?: boolean;
};

// Field with arguments (e.g. requestUploadUrl on bucket types)
if (nested.args && typeof nested.args === 'object') {
const fieldArgs = Object.entries(nested.args).map(
([argName, argValue]) =>
t.argument({ name: argName, value: buildValueAst(argValue) }),
);
const nestedSelect = nested.select;
if (nestedSelect && typeof nestedSelect === 'object') {
const subSelections = Object.entries(nestedSelect)
.filter(([, v]) => v)
.map(([name]) => t.field({ name }));
fields.push(
t.field({
name: key,
args: fieldArgs.length ? fieldArgs : undefined,
selectionSet: subSelections.length
? t.selectionSet({ selections: subSelections })
: undefined,
}),
);
} else {
fields.push(
t.field({
name: key,
args: fieldArgs.length ? fieldArgs : undefined,
}),
);
}
continue;
}

if (!nested.select || typeof nested.select !== 'object') {
throw new Error(
`Invalid selection for field "${key}": nested selections must include a "select" object.`,
Expand Down
17 changes: 17 additions & 0 deletions graphql/codegen/src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ export interface Field {
isNotNull?: boolean | null;
/** Whether the column has a DEFAULT value (inferred by comparing entity vs CreateInput field nullability) */
hasDefault?: boolean | null;
/** Arguments for computed fields (e.g. requestUploadUrl on bucket types) */
args?: FieldArgument[];
}

/**
* Argument on a computed field (not a root operation)
*/
export interface FieldArgument {
name: string;
/** GraphQL type reference */
type: TypeRef;
/** Whether this argument is required (NON_NULL) */
isRequired: boolean;
/** Description from schema */
description?: string;
/** Default value (as string) */
defaultValue?: string;
}

/**
Expand Down
67 changes: 63 additions & 4 deletions graphql/query/src/generators/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
import * as t from 'gql-ast';
import { Kind, OperationTypeNode, print } from 'graphql';
import type { ArgumentNode, FieldNode, VariableDefinitionNode } from 'graphql';
import type { ArgumentNode, FieldNode, TypeNode, VariableDefinitionNode } from 'graphql';

import { TypedDocumentString } from '../client/typed-document';
import {
Expand All @@ -22,7 +22,7 @@ import type {
QuerySelectionOptions,
} from '../types';
import type { QueryOptions } from '../types/query';
import type { Table } from '../types/schema';
import type { Table, TypeRef } from '../types/schema';
import type { FieldSelection } from '../types/selection';
import { convertToSelectionOptions, isRelationalField } from './field-selector';
import { fuzzyFindByName } from 'inflekt';
Expand Down Expand Up @@ -356,12 +356,14 @@ function generateSelectQueryAST(
): string {
const pluralName = toCamelCasePlural(table.name, table);

// Generate field selections
// Generate field selections, collecting any variable definitions from field args
const fieldArgVarDefs: VariableDefinitionNode[] = [];
const fieldSelections = generateFieldSelectionsFromOptions(
table,
allTables,
selection,
relationFieldMap,
fieldArgVarDefs,
);

// Build the query AST
Expand Down Expand Up @@ -505,7 +507,7 @@ function generateSelectQueryAST(
t.operationDefinition({
operation: OperationTypeNode.QUERY,
name: `${pluralName}Query`,
variableDefinitions,
variableDefinitions: [...variableDefinitions, ...fieldArgVarDefs],
selectionSet: t.selectionSet({
selections: [
t.field({
Expand All @@ -524,6 +526,20 @@ function generateSelectQueryAST(
return print(ast);
}

/**
* Convert a TypeRef to a GraphQL AST type node for variable definitions
*/
function typeRefToGqlAstType(ref: TypeRef): TypeNode {
if (ref.kind === 'NON_NULL' && ref.ofType) {
const inner = typeRefToGqlAstType(ref.ofType);
return t.nonNullType({ type: inner as ReturnType<typeof t.namedType> });
}
if (ref.kind === 'LIST' && ref.ofType) {
return t.listType({ type: typeRefToGqlAstType(ref.ofType) as ReturnType<typeof t.namedType> });
}
return t.namedType({ type: ref.name ?? 'String' });
}

/**
* Generate field selections from SelectionOptions
*/
Expand All @@ -532,6 +548,7 @@ function generateFieldSelectionsFromOptions(
allTables: Table[],
selection: QuerySelectionOptions | null,
relationFieldMap?: Record<string, string | null>,
collectedVarDefs?: VariableDefinitionNode[],
): FieldNode[] {
const DEFAULT_NESTED_RELATION_FIRST = 20;

Expand Down Expand Up @@ -570,6 +587,48 @@ function generateFieldSelectionsFromOptions(
createFieldSelectionNode(resolvedField.name, resolvedField.alias),
);
}
} else if (typeof fieldOptions === 'object' && fieldOptions.args) {
// Field with arguments (e.g. requestUploadUrl on bucket types)
const fieldDef = table.fields.find((f) => f.name === fieldName);
const fieldArgDefs = fieldDef?.args ?? [];
const fieldArgNodes: ArgumentNode[] = [];

for (const [argName, _argValue] of Object.entries(fieldOptions.args)) {
const varName = `${fieldName}_${argName}`;
const argDef = fieldArgDefs.find((a) => a.name === argName);
const gqlType = argDef
? typeRefToGqlAstType(argDef.type)
: t.namedType({ type: 'String' });

collectedVarDefs?.push(
t.variableDefinition({
variable: t.variable({ name: varName }),
type: gqlType,
}),
);
fieldArgNodes.push(
t.argument({
name: argName,
value: t.variable({ name: varName }),
}),
);
}

const nestedSelectObj = fieldOptions.select;
const nestedFields: FieldNode[] = Object.entries(nestedSelectObj)
.filter(([, include]) => include)
.map(([nestedField]) => t.field({ name: nestedField }));

fieldSelections.push(
createFieldSelectionNode(
resolvedField.name,
resolvedField.alias,
fieldArgNodes,
nestedFields.length > 0
? t.selectionSet({ selections: nestedFields })
: undefined,
),
);
} else if (typeof fieldOptions === 'object' && fieldOptions.select) {
// Nested field selection (for relation fields)
const nestedSelections: FieldNode[] = [];
Expand Down
42 changes: 42 additions & 0 deletions graphql/query/src/introspect/infer-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getBaseTypeName, isList, isNonNull, unwrapType } from '../types/introsp
import type {
BelongsToRelation,
Field,
FieldArgument,
FieldType,
HasManyRelation,
ManyToManyRelation,
Expand All @@ -36,6 +37,7 @@ import type {
TableConstraints,
TableInflection,
TableQueryNames,
TypeRef,
} from '../types/schema';

// ============================================================================
Expand Down Expand Up @@ -395,12 +397,14 @@ function extractEntityFields(

// Include scalar, enum, and other non-relation fields
const fieldDescription = commentsEnabled ? stripSmartComments(field.description) : undefined;
const fieldArgs = extractFieldArguments(field);
fields.push({
name: field.name,
...(fieldDescription ? { description: fieldDescription } : {}),
type: convertToCleanFieldType(field.type),
isNotNull: fieldIsNotNull,
hasDefault: fieldHasDefault,
...(fieldArgs.length > 0 ? { args: fieldArgs } : {}),
});
}

Expand Down Expand Up @@ -461,6 +465,44 @@ function convertToCleanFieldType(
};
}

/**
* Convert an IntrospectionTypeRef to a clean TypeRef
*/
function introspectionTypeRefToTypeRef(typeRef: IntrospectionTypeRef): TypeRef {
if (typeRef.kind === 'NON_NULL' && typeRef.ofType) {
return {
kind: 'NON_NULL',
name: null,
ofType: introspectionTypeRefToTypeRef(typeRef.ofType),
};
}
if (typeRef.kind === 'LIST' && typeRef.ofType) {
return {
kind: 'LIST',
name: null,
ofType: introspectionTypeRefToTypeRef(typeRef.ofType),
};
}
return {
kind: typeRef.kind as TypeRef['kind'],
name: typeRef.name ?? null,
};
}

/**
* Extract arguments from a field that has them (computed fields with args)
*/
function extractFieldArguments(field: IntrospectionField): FieldArgument[] {
if (!field.args || field.args.length === 0) return [];
return field.args.map((arg) => ({
name: arg.name,
type: introspectionTypeRefToTypeRef(arg.type),
isRequired: isNonNull(arg.type),
...(arg.description ? { description: arg.description } : {}),
...(arg.defaultValue != null ? { defaultValue: arg.defaultValue } : {}),
}));
}

// ============================================================================
// Relation Inference
// ============================================================================
Expand Down
3 changes: 2 additions & 1 deletion graphql/query/src/types/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ export interface QuerySelectionOptions {
[fieldName: string]:
| boolean
| {
select: Record<string, boolean>;
select: Record<string, boolean | QuerySelectionOptions>;
variables?: GraphQLVariables;
args?: Record<string, unknown>;
};
}

Expand Down
Loading
Loading