Skip to content

feat(package/cli): add params to args conversion to generator #2056

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
5 changes: 5 additions & 0 deletions .changeset/thick-wolves-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gqty': patch
---

skip numeric selection keys in optimistic updates
85 changes: 85 additions & 0 deletions packages/cli/src/generate/convert-params-to-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { type Schema } from 'gqty';

/**
* Generates TypeScript code that defines:
* 1) convertParamsToArgsFn<T>(argNames: string[], params: unknown[]): T
* - Creates a normal object (not null-prototype)
* - Omits undefined values
*
* 2) convertParamsToArgs = {
* Mutation: { ... },
* Query: { ... }
* }
* - Each method calls convertParamsToArgsFn with the appropriate
* ParamNames and typed parameters.
*
* @param generatedSchema - The GQty generated schema (with query/mutation definitions).
* @returns A string of TypeScript code to be appended to the final schema file.
*/
export function generateConvertParamsToArgs(generatedSchema: Schema): string {
// Start with the function definition
let code = `
export function convertParamsToArgsFn<T>(argNames: string[], params: unknown[]): T {
const result: Record<string, unknown> = {};

argNames.forEach((key, index) => {
const value = params[index];
// Only set the property if it's not undefined
if (value !== undefined) {
result[key] = value;
}
});

return result as T;
}
`;

// Build the convertParamsToArgs object
let mutationMethods = '';
if (generatedSchema.mutation) {
for (const fieldName of Object.keys(generatedSchema.mutation)) {
if (fieldName === '__typename') continue;
const fieldValue = generatedSchema.mutation[fieldName];
// Only generate a method if the field has arguments
if (!fieldValue.__args || !Object.keys(fieldValue.__args).length)
continue;

mutationMethods += `
${fieldName}(params: MutationTypes["${fieldName}"]["params"]): Parameters<Mutation["${fieldName}"]>[0] {
return convertParamsToArgsFn<Parameters<Mutation["${fieldName}"]>[0]>(
MutationParamNames["${fieldName}"],
params
);
},`;
}
}

let queryMethods = '';
if (generatedSchema.query) {
for (const fieldName of Object.keys(generatedSchema.query)) {
if (fieldName === '__typename') continue;
const fieldValue = generatedSchema.query[fieldName];
if (!fieldValue.__args || !Object.keys(fieldValue.__args).length)
continue;

queryMethods += `
${fieldName}(params: QueryTypes["${fieldName}"]["params"]): Parameters<Query["${fieldName}"]>[0] {
return convertParamsToArgsFn<Parameters<Query["${fieldName}"]>[0]>(
QueryParamNames["${fieldName}"],
params
);
},`;
}
}

code += `
export const convertParamsToArgs = {
Mutation: {${mutationMethods}
},
Query: {${queryMethods}
}
};
`;

return code;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ import type {
GraphQLUnionType,
} from 'graphql';
import * as graphql from 'graphql';
import { defaultConfig, type GQtyConfig } from './config';
import * as deps from './deps';
import { formatPrettier } from './prettier';
import { defaultConfig, type GQtyConfig } from '../config';
import * as deps from '../deps';
import { formatPrettier } from '../prettier';

import { generateMutationQueryTypes } from './mutation-query-types';
import { generateMutationQueryParamNames } from './mutation-query-param-names';
import { generateConvertParamsToArgs } from './convert-params-to-args';

const {
isEnumType,
Expand Down Expand Up @@ -173,7 +177,16 @@ export async function generate(
parser: 'typescript',
});

schema = lexicographicSortSchema(assertSchema(schema));
react ??= frameworks.includes('react');
const solid = frameworks.includes('solid-js');

const requiresSchemaSorting = false;

if (requiresSchemaSorting) {
schema = lexicographicSortSchema(assertSchema(schema));
} else {
schema = assertSchema(schema);
}

if (transformSchema) {
schema = await transformSchema(schema, graphql);
Expand All @@ -183,9 +196,6 @@ export async function generate(
}
}

react ??= frameworks.includes('react');
const solid = frameworks.includes('solid-js');

const codegenResultPromise = deps.codegen({
schema: parse(deps.printSchemaWithDirectives(schema)),
config: {} satisfies deps.typescriptPlugin.TypeScriptPluginConfig,
Expand Down Expand Up @@ -858,6 +868,18 @@ export async function generate(
export const generatedSchema = {${generatedSchemaCodeString}};
`);

const paramsToArgsSchemaCode = await format(`
/**
* Contains code for parameter to argument conversion.
*/

${generateMutationQueryTypes(generatedSchema, scalarsEnumsHash)}

${generateMutationQueryParamNames(generatedSchema)}

${generateConvertParamsToArgs(generatedSchema)}
`);

const imports = [
hasUnions && 'SchemaUnionsKey',
!isJavascriptOutput && 'type ScalarsEnumsHash',
Expand All @@ -884,6 +906,8 @@ export async function generate(
} {${generatedSchemaCodeString}}${isJavascriptOutput ? '' : ' as const'};

${typescriptTypes}

${paramsToArgsSchemaCode}
`);

const reactClientCode = react
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/generate/mutation-query-param-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type Schema } from 'gqty';

/**
* Builds and returns TypeScript code for `MutationParamNames` and `QueryParamNames` objects,
* each containing the argument names for every field that actually has arguments.
*
* @param generatedSchema - GQty's generated schema object (`query`, `mutation`, etc.)
* @returns A string of TypeScript code with the two objects declared.
*/
export function generateMutationQueryParamNames(
generatedSchema: Schema
): string {
let code = '';

// Handle "mutation" and "query"
(['mutation', 'query'] as const).forEach((opName) => {
const opFields = generatedSchema[opName];
if (!opFields) return;

// Collect property lines, e.g. "userCreate: ['values', 'organizationId'...]"
const lines: string[] = [];

for (const fieldName of Object.keys(opFields)) {
if (fieldName === '__typename') continue;

const field = opFields[fieldName];
if (!field.__args) continue;

const argNamesInOrder = Object.keys(field.__args);

if (argNamesInOrder.length) {
const arr = argNamesInOrder.map((arg) => `"${arg}"`).join(', ');
lines.push(` ${fieldName}: [${arr}]`);
}
}

if (!lines.length) return;

// E.g. "export const MutationParamNames = { userCreate: [...], ... };"
const capitalized = opName.charAt(0).toUpperCase() + opName.slice(1);
code += `export const ${capitalized}ParamNames = {\n${lines.join(',\n')}\n};\n`;
});

return code;
}
134 changes: 134 additions & 0 deletions packages/cli/src/generate/mutation-query-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { parseSchemaType, type Schema, type ScalarsEnumsHash } from 'gqty';

/**
* Generates code for two interfaces: `MutationTypes` and `QueryTypes`.
*
* Each interface entry looks like:
* fieldName: {
* params: [arg1?: ..., arg2: ...],
* return: SomeReturnType
* };
*
* For each argument and return type, we rely on `parseSchemaType(...)`
* to detect arrays, nullability, etc., then convert them to TypeScript
* (e.g. `Array<Maybe<ScalarsEnums["String"]>>`).
*/
export function generateMutationQueryTypes(
generatedSchema: Schema,
scalarsEnumsHash: ScalarsEnumsHash
): string {
let code = '';

// If there's a "mutation" object, build "MutationTypes"
if (generatedSchema.mutation) {
code += makeOperationInterface(
'mutation',
'MutationTypes',
generatedSchema,
scalarsEnumsHash
);
}

// If there's a "query" object, build "QueryTypes"
if (generatedSchema.query) {
code += makeOperationInterface(
'query',
'QueryTypes',
generatedSchema,
scalarsEnumsHash
);
}

return code;
}

/**
* Builds an interface for either "mutation" or "query".
* E.g. "export interface MutationTypes { userCreate: {...}; }".
*/
function makeOperationInterface(
opKey: 'mutation' | 'query',
interfaceName: string,
generatedSchema: Schema,
scalarsEnumsHash: ScalarsEnumsHash
) {
const operationFields = generatedSchema[opKey];
if (!operationFields) return '';

const fieldNames = Object.keys(operationFields).filter(
(name) => name !== '__typename'
);
if (!fieldNames.length) return '';

// Accumulate lines for each field that has arguments
const lines: string[] = [];

for (const fieldName of fieldNames) {
const fieldValue = operationFields[fieldName];
if (!fieldValue.__args || !Object.keys(fieldValue.__args).length) {
// Skip fields with no arguments
continue;
}

// Build a 'params: [ ... ]' tuple using parseSchemaType for each arg
const argEntries = Object.entries(fieldValue.__args);
const argLines = argEntries.map(([argName, argTypeString]) => {
const parsed = parseSchemaType(argTypeString);
const tsType = buildTsTypeFromParsed(parsed, scalarsEnumsHash);
// If it's required => "argName: tsType", else => "argName?: tsType"
const isRequired = !parsed.isNullable && !parsed.hasDefaultValue;
return isRequired ? `${argName}: ${tsType}` : `${argName}?: ${tsType}`;
});

// Build the return type
const returnParsed = parseSchemaType(fieldValue.__type);
const returnTsType = buildTsTypeFromParsed(returnParsed, scalarsEnumsHash);

lines.push(`
${fieldName}: {
params: [${argLines.join(', ')}];
return: ${returnTsType};
};`);
}

if (!lines.length) return '';

return `
export interface ${interfaceName} {${lines.join('')}
}
`;
}

/**
* Converts the parsed type info (via `parseSchemaType`) into a final TS type string.
* e.g. "ScalarsEnums["String"]", "Array<Maybe<ScalarsEnums["Int"]>>", "MyObject", ...
*/
function buildTsTypeFromParsed(
parsed: ReturnType<typeof parseSchemaType>,
scalarsEnumsHash: ScalarsEnumsHash
): string {
const { pureType, isArray, nullableItems, isNullable, hasDefaultValue } =
parsed;

// If recognized as a scalar or enum => "ScalarsEnums["pureType"]", else use pureType
let baseType = scalarsEnumsHash[pureType]
? `ScalarsEnums["${pureType}"]`
: pureType;

// If it's an array, wrap in Array<...>, possibly with Maybe<...> for items
if (isArray) {
if (nullableItems) {
baseType = `Array<Maybe<${baseType}>>`;
} else {
baseType = `Array<${baseType}>`;
}
}

// If the field is nullable or has a default, wrap the entire thing in Maybe<...>
// (This matches GQty's typical approach.)
if (isNullable || hasDefaultValue) {
baseType = `Maybe<${baseType}>`;
}

return baseType;
}
3 changes: 3 additions & 0 deletions packages/gqty/src/Accessor/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,9 @@ const objectProxyHandler: ProxyHandler<GeneratedSchemaObject> = {
for (const [keys, scalar] of flattenObject(value)) {
let currentSelection = selection.getChild(key);
for (const key of keys) {
// Skip array indices
if (!isNaN(Number(key))) continue;

currentSelection = currentSelection.getChild(key);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/gqty/src/Cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export class Cache {
const nsubs = this.#normalizedSubscriptions;
const getId = this.normalizationOptions?.identity;

for (const [paths, notify] of this.#subscriptions) {
for (const [paths, notify] of subs) {
for (const path of paths) {
const parts = path.split('.');
const node = select(value, parts, (node) => {
Expand Down