Skip to content

Commit c6e143b

Browse files
authored
Merge pull request #1076 from constructive-io/feat/codegen-fields-with-arguments
feat(codegen): add fields-with-arguments support for ORM select types
2 parents 69e74dd + 8ce5584 commit c6e143b

8 files changed

Lines changed: 238 additions & 14 deletions

File tree

graphql/codegen/src/core/codegen/orm/input-types-generator.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -842,15 +842,62 @@ function buildSelectTypeLiteral(
842842
): t.TSTypeLiteral {
843843
const members: t.TSTypeElement[] = [];
844844

845-
// Add scalar fields
845+
// Add scalar fields (and fields with arguments)
846846
for (const field of table.fields) {
847847
if (!isRelationField(field.name, table)) {
848-
const prop = t.tsPropertySignature(
849-
t.identifier(field.name),
850-
t.tsTypeAnnotation(t.tsBooleanKeyword()),
851-
);
852-
prop.optional = true;
853-
members.push(prop);
848+
if (field.args && field.args.length > 0) {
849+
// Field with arguments (e.g. requestUploadUrl on bucket types)
850+
const argMembers: t.TSTypeElement[] = field.args.map((arg) => {
851+
const tsType = typeRefToTs(arg.type);
852+
const argProp = t.tsPropertySignature(
853+
t.identifier(arg.name),
854+
t.tsTypeAnnotation(parseTypeString(tsType)),
855+
);
856+
argProp.optional = !arg.isRequired;
857+
return argProp;
858+
});
859+
860+
const prop = t.tsPropertySignature(
861+
t.identifier(field.name),
862+
t.tsTypeAnnotation(
863+
t.tsTypeLiteral([
864+
(() => {
865+
const argsProp = t.tsPropertySignature(
866+
t.identifier('args'),
867+
t.tsTypeAnnotation(t.tsTypeLiteral(argMembers)),
868+
);
869+
argsProp.optional = false;
870+
return argsProp;
871+
})(),
872+
(() => {
873+
const selectProp = t.tsPropertySignature(
874+
t.identifier('select'),
875+
t.tsTypeAnnotation(
876+
t.tsTypeReference(
877+
t.identifier('Record'),
878+
t.tsTypeParameterInstantiation([
879+
t.tsStringKeyword(),
880+
t.tsBooleanKeyword(),
881+
]),
882+
),
883+
),
884+
);
885+
selectProp.optional = true;
886+
return selectProp;
887+
})(),
888+
]),
889+
),
890+
);
891+
prop.optional = true;
892+
members.push(prop);
893+
} else {
894+
const prop = t.tsPropertySignature(
895+
t.identifier(field.name),
896+
t.tsTypeAnnotation(t.tsBooleanKeyword()),
897+
);
898+
prop.optional = true;
899+
members.push(prop);
900+
}
854901
}
855902
}
856903

graphql/codegen/src/core/codegen/orm/select-types.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type SelectConfig<TFields extends string> = {
4242
*/
4343
export interface NestedSelectConfig {
4444
select?: Record<string, boolean | NestedSelectConfig>;
45+
args?: Record<string, unknown>;
4546
first?: number;
4647
last?: number;
4748
after?: string;
@@ -140,15 +141,23 @@ export type InferSelectResult<TEntity, TSelect> = TSelect extends undefined
140141
? K extends keyof TEntity
141142
? TEntity[K]
142143
: never
143-
: TSelect[K] extends { select: infer NestedSelect }
144+
: TSelect[K] extends { args: Record<string, unknown>; select: infer NestedSelect }
144145
? K extends keyof TEntity
145146
? NonNullable<TEntity[K]> extends ConnectionResult<infer NodeType>
146147
? ConnectionResult<InferSelectResult<NodeType, NestedSelect>>
147148
:
148149
| InferSelectResult<NonNullable<TEntity[K]>, NestedSelect>
149150
| (null extends TEntity[K] ? null : never)
150151
: never
151-
: K extends keyof TEntity
152+
: TSelect[K] extends { select: infer NestedSelect }
153+
? K extends keyof TEntity
154+
? NonNullable<TEntity[K]> extends ConnectionResult<infer NodeType>
155+
? ConnectionResult<InferSelectResult<NodeType, NestedSelect>>
156+
:
157+
| InferSelectResult<NonNullable<TEntity[K]>, NestedSelect>
158+
| (null extends TEntity[K] ? null : never)
159+
: never
160+
: K extends keyof TEntity
152161
? TEntity[K]
153162
: never;
154163
};

graphql/codegen/src/core/codegen/templates/query-builder.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,44 @@ export function buildSelections(
138138
if (typeof value === 'object' && value !== null) {
139139
const nested = value as {
140140
select?: Record<string, unknown>;
141+
args?: Record<string, unknown>;
141142
first?: number;
142143
filter?: Record<string, unknown>;
143144
orderBy?: string[];
144145
connection?: boolean;
145146
};
146147

148+
// Field with arguments (e.g. requestUploadUrl on bucket types)
149+
if (nested.args && typeof nested.args === 'object') {
150+
const fieldArgs = Object.entries(nested.args).map(
151+
([argName, argValue]) =>
152+
t.argument({ name: argName, value: buildValueAst(argValue) }),
153+
);
154+
const nestedSelect = nested.select;
155+
if (nestedSelect && typeof nestedSelect === 'object') {
156+
const subSelections = Object.entries(nestedSelect)
157+
.filter(([, v]) => v)
158+
.map(([name]) => t.field({ name }));
159+
fields.push(
160+
t.field({
161+
name: key,
162+
args: fieldArgs.length ? fieldArgs : undefined,
163+
selectionSet: subSelections.length
164+
? t.selectionSet({ selections: subSelections })
165+
: undefined,
166+
}),
167+
);
168+
} else {
169+
fields.push(
170+
t.field({
171+
name: key,
172+
args: fieldArgs.length ? fieldArgs : undefined,
173+
}),
174+
);
175+
}
176+
continue;
177+
}
178+
147179
if (!nested.select || typeof nested.select !== 'object') {
148180
throw new Error(
149181
`Invalid selection for field "${key}": nested selections must include a "select" object.`,

graphql/codegen/src/types/schema.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ export interface Field {
119119
isNotNull?: boolean | null;
120120
/** Whether the column has a DEFAULT value (inferred by comparing entity vs CreateInput field nullability) */
121121
hasDefault?: boolean | null;
122+
/** Arguments for computed fields (e.g. requestUploadUrl on bucket types) */
123+
args?: FieldArgument[];
124+
}
125+
126+
/**
127+
* Argument on a computed field (not a root operation)
128+
*/
129+
export interface FieldArgument {
130+
name: string;
131+
/** GraphQL type reference */
132+
type: TypeRef;
133+
/** Whether this argument is required (NON_NULL) */
134+
isRequired: boolean;
135+
/** Description from schema */
136+
description?: string;
137+
/** Default value (as string) */
138+
defaultValue?: string;
122139
}
123140

124141
/**

graphql/query/src/generators/select.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
import * as t from 'gql-ast';
66
import { Kind, OperationTypeNode, print } from 'graphql';
7-
import type { ArgumentNode, FieldNode, VariableDefinitionNode } from 'graphql';
7+
import type { ArgumentNode, FieldNode, TypeNode, VariableDefinitionNode } from 'graphql';
88

99
import { TypedDocumentString } from '../client/typed-document';
1010
import {
@@ -22,7 +22,7 @@ import type {
2222
QuerySelectionOptions,
2323
} from '../types';
2424
import type { QueryOptions } from '../types/query';
25-
import type { Table } from '../types/schema';
25+
import type { Table, TypeRef } from '../types/schema';
2626
import type { FieldSelection } from '../types/selection';
2727
import { convertToSelectionOptions, isRelationalField } from './field-selector';
2828
import { fuzzyFindByName } from 'inflekt';
@@ -356,12 +356,14 @@ function generateSelectQueryAST(
356356
): string {
357357
const pluralName = toCamelCasePlural(table.name, table);
358358

359-
// Generate field selections
359+
// Generate field selections, collecting any variable definitions from field args
360+
const fieldArgVarDefs: VariableDefinitionNode[] = [];
360361
const fieldSelections = generateFieldSelectionsFromOptions(
361362
table,
362363
allTables,
363364
selection,
364365
relationFieldMap,
366+
fieldArgVarDefs,
365367
);
366368

367369
// Build the query AST
@@ -505,7 +507,7 @@ function generateSelectQueryAST(
505507
t.operationDefinition({
506508
operation: OperationTypeNode.QUERY,
507509
name: `${pluralName}Query`,
508-
variableDefinitions,
510+
variableDefinitions: [...variableDefinitions, ...fieldArgVarDefs],
509511
selectionSet: t.selectionSet({
510512
selections: [
511513
t.field({
@@ -524,6 +526,20 @@ function generateSelectQueryAST(
524526
return print(ast);
525527
}
526528

529+
/**
530+
* Convert a TypeRef to a GraphQL AST type node for variable definitions
531+
*/
532+
function typeRefToGqlAstType(ref: TypeRef): TypeNode {
533+
if (ref.kind === 'NON_NULL' && ref.ofType) {
534+
const inner = typeRefToGqlAstType(ref.ofType);
535+
return t.nonNullType({ type: inner as ReturnType<typeof t.namedType> });
536+
}
537+
if (ref.kind === 'LIST' && ref.ofType) {
538+
return t.listType({ type: typeRefToGqlAstType(ref.ofType) as ReturnType<typeof t.namedType> });
539+
}
540+
return t.namedType({ type: ref.name ?? 'String' });
541+
}
542+
527543
/**
528544
* Generate field selections from SelectionOptions
529545
*/
@@ -532,6 +548,7 @@ function generateFieldSelectionsFromOptions(
532548
allTables: Table[],
533549
selection: QuerySelectionOptions | null,
534550
relationFieldMap?: Record<string, string | null>,
551+
collectedVarDefs?: VariableDefinitionNode[],
535552
): FieldNode[] {
536553
const DEFAULT_NESTED_RELATION_FIRST = 20;
537554

@@ -570,6 +587,48 @@ function generateFieldSelectionsFromOptions(
570587
createFieldSelectionNode(resolvedField.name, resolvedField.alias),
571588
);
572589
}
590+
} else if (typeof fieldOptions === 'object' && fieldOptions.args) {
591+
// Field with arguments (e.g. requestUploadUrl on bucket types)
592+
const fieldDef = table.fields.find((f) => f.name === fieldName);
593+
const fieldArgDefs = fieldDef?.args ?? [];
594+
const fieldArgNodes: ArgumentNode[] = [];
595+
596+
for (const [argName, _argValue] of Object.entries(fieldOptions.args)) {
597+
const varName = `${fieldName}_${argName}`;
598+
const argDef = fieldArgDefs.find((a) => a.name === argName);
599+
const gqlType = argDef
600+
? typeRefToGqlAstType(argDef.type)
601+
: t.namedType({ type: 'String' });
602+
603+
collectedVarDefs?.push(
604+
t.variableDefinition({
605+
variable: t.variable({ name: varName }),
606+
type: gqlType,
607+
}),
608+
);
609+
fieldArgNodes.push(
610+
t.argument({
611+
name: argName,
612+
value: t.variable({ name: varName }),
613+
}),
614+
);
615+
}
616+
617+
const nestedSelectObj = fieldOptions.select;
618+
const nestedFields: FieldNode[] = Object.entries(nestedSelectObj)
619+
.filter(([, include]) => include)
620+
.map(([nestedField]) => t.field({ name: nestedField }));
621+
622+
fieldSelections.push(
623+
createFieldSelectionNode(
624+
resolvedField.name,
625+
resolvedField.alias,
626+
fieldArgNodes,
627+
nestedFields.length > 0
628+
? t.selectionSet({ selections: nestedFields })
629+
: undefined,
630+
),
631+
);
573632
} else if (typeof fieldOptions === 'object' && fieldOptions.select) {
574633
// Nested field selection (for relation fields)
575634
const nestedSelections: FieldNode[] = [];

graphql/query/src/introspect/infer-tables.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { getBaseTypeName, isList, isNonNull, unwrapType } from '../types/introsp
2727
import type {
2828
BelongsToRelation,
2929
Field,
30+
FieldArgument,
3031
FieldType,
3132
HasManyRelation,
3233
ManyToManyRelation,
@@ -36,6 +37,7 @@ import type {
3637
TableConstraints,
3738
TableInflection,
3839
TableQueryNames,
40+
TypeRef,
3941
} from '../types/schema';
4042

4143
// ============================================================================
@@ -395,12 +397,14 @@ function extractEntityFields(
395397

396398
// Include scalar, enum, and other non-relation fields
397399
const fieldDescription = commentsEnabled ? stripSmartComments(field.description) : undefined;
400+
const fieldArgs = extractFieldArguments(field);
398401
fields.push({
399402
name: field.name,
400403
...(fieldDescription ? { description: fieldDescription } : {}),
401404
type: convertToCleanFieldType(field.type),
402405
isNotNull: fieldIsNotNull,
403406
hasDefault: fieldHasDefault,
407+
...(fieldArgs.length > 0 ? { args: fieldArgs } : {}),
404408
});
405409
}
406410

@@ -461,6 +465,44 @@ function convertToCleanFieldType(
461465
};
462466
}
463467

468+
/**
469+
* Convert an IntrospectionTypeRef to a clean TypeRef
470+
*/
471+
function introspectionTypeRefToTypeRef(typeRef: IntrospectionTypeRef): TypeRef {
472+
if (typeRef.kind === 'NON_NULL' && typeRef.ofType) {
473+
return {
474+
kind: 'NON_NULL',
475+
name: null,
476+
ofType: introspectionTypeRefToTypeRef(typeRef.ofType),
477+
};
478+
}
479+
if (typeRef.kind === 'LIST' && typeRef.ofType) {
480+
return {
481+
kind: 'LIST',
482+
name: null,
483+
ofType: introspectionTypeRefToTypeRef(typeRef.ofType),
484+
};
485+
}
486+
return {
487+
kind: typeRef.kind as TypeRef['kind'],
488+
name: typeRef.name ?? null,
489+
};
490+
}
491+
492+
/**
493+
* Extract arguments from a field that has them (computed fields with args)
494+
*/
495+
function extractFieldArguments(field: IntrospectionField): FieldArgument[] {
496+
if (!field.args || field.args.length === 0) return [];
497+
return field.args.map((arg) => ({
498+
name: arg.name,
499+
type: introspectionTypeRefToTypeRef(arg.type),
500+
isRequired: isNonNull(arg.type),
501+
...(arg.description ? { description: arg.description } : {}),
502+
...(arg.defaultValue != null ? { defaultValue: arg.defaultValue } : {}),
503+
}));
504+
}
505+
464506
// ============================================================================
465507
// Relation Inference
466508
// ============================================================================

graphql/query/src/types/core.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,9 @@ export interface QuerySelectionOptions {
121121
[fieldName: string]:
122122
| boolean
123123
| {
124-
select: Record<string, boolean>;
124+
select: Record<string, boolean | QuerySelectionOptions>;
125125
variables?: GraphQLVariables;
126+
args?: Record<string, unknown>;
126127
};
127128
}
128129

0 commit comments

Comments
 (0)