Skip to content
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