diff --git a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts index 571b8975a4..37c94f3168 100644 --- a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts @@ -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); + } } } diff --git a/graphql/codegen/src/core/codegen/orm/select-types.ts b/graphql/codegen/src/core/codegen/orm/select-types.ts index 7bc20bad92..f5932fc0a7 100644 --- a/graphql/codegen/src/core/codegen/orm/select-types.ts +++ b/graphql/codegen/src/core/codegen/orm/select-types.ts @@ -42,6 +42,7 @@ export type SelectConfig = { */ export interface NestedSelectConfig { select?: Record; + args?: Record; first?: number; last?: number; after?: string; @@ -140,7 +141,7 @@ export type InferSelectResult = TSelect extends undefined ? K extends keyof TEntity ? TEntity[K] : never - : TSelect[K] extends { select: infer NestedSelect } + : TSelect[K] extends { args: Record; select: infer NestedSelect } ? K extends keyof TEntity ? NonNullable extends ConnectionResult ? ConnectionResult> @@ -148,7 +149,15 @@ export type InferSelectResult = TSelect extends undefined | InferSelectResult, NestedSelect> | (null extends TEntity[K] ? null : never) : never - : K extends keyof TEntity + : TSelect[K] extends { select: infer NestedSelect } + ? K extends keyof TEntity + ? NonNullable extends ConnectionResult + ? ConnectionResult> + : + | InferSelectResult, NestedSelect> + | (null extends TEntity[K] ? null : never) + : never + : K extends keyof TEntity ? TEntity[K] : never; }; diff --git a/graphql/codegen/src/core/codegen/templates/query-builder.ts b/graphql/codegen/src/core/codegen/templates/query-builder.ts index 8700136288..71025cf1fb 100644 --- a/graphql/codegen/src/core/codegen/templates/query-builder.ts +++ b/graphql/codegen/src/core/codegen/templates/query-builder.ts @@ -138,12 +138,44 @@ export function buildSelections( if (typeof value === 'object' && value !== null) { const nested = value as { select?: Record; + args?: Record; first?: number; filter?: Record; 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.`, diff --git a/graphql/codegen/src/types/schema.ts b/graphql/codegen/src/types/schema.ts index b52e8d5776..bc16a3e422 100644 --- a/graphql/codegen/src/types/schema.ts +++ b/graphql/codegen/src/types/schema.ts @@ -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; } /** diff --git a/graphql/query/src/generators/select.ts b/graphql/query/src/generators/select.ts index fe9c28c6f1..1d098d89d0 100644 --- a/graphql/query/src/generators/select.ts +++ b/graphql/query/src/generators/select.ts @@ -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 { @@ -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'; @@ -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 @@ -505,7 +507,7 @@ function generateSelectQueryAST( t.operationDefinition({ operation: OperationTypeNode.QUERY, name: `${pluralName}Query`, - variableDefinitions, + variableDefinitions: [...variableDefinitions, ...fieldArgVarDefs], selectionSet: t.selectionSet({ selections: [ t.field({ @@ -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 }); + } + if (ref.kind === 'LIST' && ref.ofType) { + return t.listType({ type: typeRefToGqlAstType(ref.ofType) as ReturnType }); + } + return t.namedType({ type: ref.name ?? 'String' }); +} + /** * Generate field selections from SelectionOptions */ @@ -532,6 +548,7 @@ function generateFieldSelectionsFromOptions( allTables: Table[], selection: QuerySelectionOptions | null, relationFieldMap?: Record, + collectedVarDefs?: VariableDefinitionNode[], ): FieldNode[] { const DEFAULT_NESTED_RELATION_FIRST = 20; @@ -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[] = []; diff --git a/graphql/query/src/introspect/infer-tables.ts b/graphql/query/src/introspect/infer-tables.ts index 9563a72528..f8d097533f 100644 --- a/graphql/query/src/introspect/infer-tables.ts +++ b/graphql/query/src/introspect/infer-tables.ts @@ -27,6 +27,7 @@ import { getBaseTypeName, isList, isNonNull, unwrapType } from '../types/introsp import type { BelongsToRelation, Field, + FieldArgument, FieldType, HasManyRelation, ManyToManyRelation, @@ -36,6 +37,7 @@ import type { TableConstraints, TableInflection, TableQueryNames, + TypeRef, } from '../types/schema'; // ============================================================================ @@ -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 } : {}), }); } @@ -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 // ============================================================================ diff --git a/graphql/query/src/types/core.ts b/graphql/query/src/types/core.ts index 881da8e99b..86bfe5137e 100644 --- a/graphql/query/src/types/core.ts +++ b/graphql/query/src/types/core.ts @@ -121,8 +121,9 @@ export interface QuerySelectionOptions { [fieldName: string]: | boolean | { - select: Record; + select: Record; variables?: GraphQLVariables; + args?: Record; }; } diff --git a/graphql/query/src/types/schema.ts b/graphql/query/src/types/schema.ts index b52e8d5776..bc16a3e422 100644 --- a/graphql/query/src/types/schema.ts +++ b/graphql/query/src/types/schema.ts @@ -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; } /**