Skip to content

Commit bd999ff

Browse files
committed
refactor(graphql): scalar-leaf default selection + caller-controlled select
Replaces the baked-in recursive selection with two pieces: - Default selection is now scalar/enum leaves of the return type only. This is always valid (leaves need no sub-selection) and small enough to stay under a server's query-complexity budget. Composite fields, and fields whose required arguments cannot be synthesized, are no longer emitted bare or argument-less. - Tools expose an optional `select` input carrying a GraphQL selection set, spliced into the operation at invoke time, so callers request nested/list data (and supply nested required arguments) per call instead of inheriting one frozen, arbitrarily-bounded selection. The old depth cap and first-12-fields truncation are gone: depth/breadth are now the caller's choice via `select`, bounded by the server's own complexity limits.
1 parent b389607 commit bd999ff

5 files changed

Lines changed: 190 additions & 95 deletions

File tree

.changeset/graphql-valid-selections.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@
22
"@executor-js/plugin-graphql": patch
33
---
44

5-
Fix the GraphQL plugin generating invalid operations against large schemas. The auto-generated selection set no longer emits composite (object/connection) fields without a sub-selection, and no longer selects nested fields whose required arguments it cannot supply. Generated tools now validate against rich schemas (such as GitLab's) instead of failing on every call against a rich return type.
5+
Fix the GraphQL plugin generating invalid operations against large schemas, and make field selection caller-controlled instead of a baked-in guess.
6+
7+
Previously each tool froze a recursive, depth- and count-bounded selection at sync time. Against a rich schema (GitLab) this produced invalid GraphQL (composite fields with no sub-selection, nested fields missing required arguments) so every call over a rich return type failed, and the arbitrary bound silently truncated which fields came back.
8+
9+
Generated tools now default to selecting only scalar/enum leaf fields of the return type (always valid, always within a server's query-complexity budget), and expose an optional `select` input carrying a GraphQL selection set so a caller can request nested or list data per call (including supplying nested required arguments). Fixes #1146.

packages/plugins/graphql/src/sdk/invoke.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,23 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* (
7171
Object.assign(variables, args.variables);
7272
}
7373

74+
// A caller-supplied `select` overrides the default scalar-leaf selection: it is
75+
// spliced into the field's selection set so nested/list data can be requested
76+
// per call. `select` is a control input, not a GraphQL variable, so it never
77+
// enters `variables`. Falls back to the stored default operation when absent or
78+
// when the binding predates the prefix/suffix split.
79+
const customSelect = typeof args.select === "string" ? args.select.trim() : "";
80+
const operationString =
81+
customSelect.length > 0 &&
82+
operation.operationPrefix != null &&
83+
operation.operationSuffix != null
84+
? `${operation.operationPrefix} { ${customSelect} }${operation.operationSuffix}`
85+
: operation.operationString;
86+
7487
let request = HttpClientRequest.post(requestEndpoint).pipe(
7588
HttpClientRequest.setHeader("Content-Type", "application/json"),
7689
HttpClientRequest.bodyJsonUnsafe({
77-
query: operation.operationString,
90+
query: operationString,
7891
variables: Object.keys(variables).length > 0 ? variables : undefined,
7992
}),
8093
);

packages/plugins/graphql/src/sdk/plugin.test.ts

Lines changed: 73 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -889,68 +889,106 @@ describe("graphqlPlugin detect URL-token fallback", () => {
889889

890890
// Issue #1146: against a large, real-world schema (GitLab) the auto-generated
891891
// operations were invalid GraphQL and every call against a rich object type
892-
// failed validation. These drive the real plugin (live introspection -> operation
893-
// generation -> invocation) against a GitLab-shaped graphql-yoga server, which
894-
// validates with graphql-js exactly like the @emulators/gitlab surface. A clean
895-
// `ok: true` therefore proves the generated operation is valid GraphQL.
892+
// failed validation. The plugin now defaults to a scalar-leaf selection (always
893+
// valid, always cheap) and lets the caller pass an explicit `select` for nested
894+
// or list data. These drive the real plugin (live introspection -> generation ->
895+
// invocation) against a GitLab-shaped graphql-yoga server, which validates with
896+
// graphql-js exactly like the @emulators/gitlab surface, so a clean `ok: true`
897+
// proves the operation that went over the wire is valid GraphQL.
896898
describe("graphqlPlugin generates valid operations against rich schemas (#1146)", () => {
897899
const gitlabServer = serveGraphqlTestServer({ schema: makeGitlab1146Schema() });
898900

899901
const lastQuery = (requests: { readonly payload: { readonly query?: string } }[]): string =>
900902
requests[requests.length - 1]?.payload.query ?? "";
901903

902-
it.effect("query.metadata: drops the nested field whose required argument it cannot fill", () =>
904+
const setup = (slug: string) =>
903905
Effect.gen(function* () {
904906
const server = yield* gitlabServer;
905907
const executor = yield* makeExecutor();
906-
907-
yield* executor.graphql.addIntegration({ endpoint: server.endpoint, slug: "gitlab" });
908+
yield* executor.graphql.addIntegration({ endpoint: server.endpoint, slug });
908909
yield* createOrgConnection(executor, {
909-
integration: "gitlab",
910+
integration: slug,
910911
name: "main",
911912
template: "none",
912913
value: "unused",
913914
});
915+
yield* server.clearRequests;
916+
return { server, executor };
917+
});
914918

919+
it.effect("default selection is scalar leaves only: valid, and never bare composites", () =>
920+
Effect.gen(function* () {
921+
const { server, executor } = yield* setup("gitlab_default");
922+
923+
// metadata: scalar leaves only. `featureFlags` (required arg) and `kas`
924+
// (composite) are omitted rather than emitted invalidly.
925+
const meta = yield* executor.execute(
926+
toolAddr("gitlab_default", "main", "query.metadata"),
927+
{},
928+
);
929+
expect(meta).toMatchObject({ ok: true });
930+
const metaQuery = lastQuery(yield* server.requests);
931+
expect(metaQuery).toContain("version");
932+
expect(metaQuery).not.toContain("featureFlags");
933+
expect(metaQuery).not.toContain("kas");
934+
935+
// currentUser: scalar leaves only. No `mergeRequests` (composite) bare.
915936
yield* server.clearRequests;
916-
const result = yield* executor.execute(toolAddr("gitlab", "main", "query.metadata"), {});
937+
const user = yield* executor.execute(
938+
toolAddr("gitlab_default", "main", "query.currentUser"),
939+
{},
940+
);
941+
expect(user).toMatchObject({ ok: true });
942+
const userQuery = lastQuery(yield* server.requests);
943+
expect(userQuery).toContain("active");
944+
expect(userQuery).not.toContain("mergeRequests");
945+
}),
946+
);
947+
948+
it.effect("a caller-supplied `select` fetches nested/list data and stays valid", () =>
949+
Effect.gen(function* () {
950+
const { server, executor } = yield* setup("gitlab_select");
951+
952+
const result = yield* executor.execute(
953+
toolAddr("gitlab_select", "main", "query.currentUser"),
954+
{ select: "active mergeRequests { count nodes { id title author { name } } }" },
955+
);
917956

918-
// Valid GraphQL: the server accepts and resolves it (no validation errors).
919957
expect(result).toMatchObject({ ok: true });
920-
// `featureFlags(names: [String!]!)` has a required arg the generator cannot
921-
// supply, so it is omitted rather than emitted without arguments.
922958
const query = lastQuery(yield* server.requests);
923-
expect(query).not.toContain("featureFlags");
924-
// Fields it CAN select are still present (no over-pruning).
925-
expect(query).toContain("kas {");
959+
expect(query).toContain("nodes {");
960+
expect(query).toContain("author {");
926961
}),
927962
);
928963

929-
it.effect("query.currentUser: never emits a composite field without a sub-selection", () =>
964+
it.effect("`select` can supply a nested field's required argument", () =>
930965
Effect.gen(function* () {
931-
const server = yield* gitlabServer;
932-
const executor = yield* makeExecutor();
966+
const { server, executor } = yield* setup("gitlab_ff");
933967

934-
yield* executor.graphql.addIntegration({ endpoint: server.endpoint, slug: "gitlab2" });
935-
yield* createOrgConnection(executor, {
936-
integration: "gitlab2",
937-
name: "main",
938-
template: "none",
939-
value: "unused",
968+
const result = yield* executor.execute(toolAddr("gitlab_ff", "main", "query.metadata"), {
969+
select: 'version featureFlags(names: ["flag_a"]) { name enabled }',
940970
});
941971

942-
yield* server.clearRequests;
943-
const result = yield* executor.execute(toolAddr("gitlab2", "main", "query.currentUser"), {});
944-
945972
expect(result).toMatchObject({ ok: true });
946-
const query = lastQuery(yield* server.requests);
947-
// The connection is traversed (proves recursion still works)...
948-
expect(query).toContain("mergeRequests {");
949-
expect(query).toContain("nodes {");
950-
// ...but composite leaves past the depth cap (`author`, `assignees`) are
951-
// dropped instead of being emitted bare, which the server would reject.
952-
expect(query).not.toContain("author");
953-
expect(query).not.toContain("assignees");
973+
expect(lastQuery(yield* server.requests)).toContain("featureFlags(names:");
974+
}),
975+
);
976+
977+
it.effect("an invalid `select` surfaces the server's validation error verbatim", () =>
978+
Effect.gen(function* () {
979+
const { executor } = yield* setup("gitlab_bad");
980+
981+
// `author` is a composite emitted bare: the plugin passes the selection
982+
// through and surfaces the server's rejection rather than silently fixing
983+
// or swallowing it.
984+
const result = yield* executor.execute(toolAddr("gitlab_bad", "main", "query.currentUser"), {
985+
select: "mergeRequests { nodes { id author } }",
986+
});
987+
988+
expect(result).toMatchObject({
989+
ok: false,
990+
error: { code: "graphql_errors" },
991+
});
954992
}),
955993
);
956994
});

packages/plugins/graphql/src/sdk/plugin.ts

Lines changed: 89 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,6 @@ const unwrapTypeName = (ref: IntrospectionTypeRef): string => {
218218
return "Unknown";
219219
};
220220

221-
// Bound the auto-generated selection so a huge schema (GitLab has 4000+ types)
222-
// does not produce an unbounded operation: cap recursion depth and the number
223-
// of fields emitted per level.
224-
const MAX_SELECTION_DEPTH = 2;
225-
const MAX_FIELDS_PER_LEVEL = 12;
226-
227221
// Composite (output) types require a sub-selection; leaves (scalars/enums) must
228222
// not have one. Anything we cannot resolve in the type map is treated as a leaf
229223
// (custom scalars live in the map as SCALAR; truly-unknown types are rare).
@@ -235,58 +229,48 @@ const isCompositeType = (
235229
return kind === "OBJECT" || kind === "INTERFACE" || kind === "UNION";
236230
};
237231

238-
// A nested field whose argument is non-null without a default cannot be selected
239-
// by the generator: it has no value to pass and emitting the field without the
240-
// argument is invalid (e.g. GitLab's `metadata.featureFlags(names:)`). Required
241-
// root-field arguments are different: those are threaded as operation variables
242-
// (and surfaced on the tool's input schema) in `buildOperationStringForField`.
232+
// A field whose argument is non-null without a default cannot be selected by the
233+
// generator: it has no value to pass and emitting the field without the argument
234+
// is invalid (e.g. GitLab's `metadata.featureFlags(names:)`). Root-field required
235+
// arguments are different: those are threaded as operation variables (and
236+
// surfaced on the tool's input schema) in `buildOperationStringForField`.
243237
const hasRequiredArgWithoutDefault = (field: IntrospectionField): boolean =>
244238
field.args.some(
245239
(arg: IntrospectionInputValue) => arg.type.kind === "NON_NULL" && arg.defaultValue == null,
246240
);
247241

248-
const buildSelectionSet = (
242+
// Build the DEFAULT selection set for a field's return type: every scalar/enum
243+
// leaf the generator can select without arguments. It deliberately does NOT
244+
// recurse into composite fields or guess at nested selections, for two reasons:
245+
// - A real schema (GitLab has 4000+ types) makes any recursive auto-expansion
246+
// either arbitrary (which N fields? how deep?) or so large the server
247+
// rejects it for exceeding its query-complexity budget.
248+
// - A bounded-but-arbitrary selection silently freezes a partial view at sync
249+
// time. Instead, callers that want nested or list data pass an explicit
250+
// `select` (see buildOperationStringForField / invoke), so the choice of
251+
// deeper fields is the caller's, not a guess baked into the tool.
252+
// The result is always valid: selecting only leaves never needs a sub-selection,
253+
// and a composite type with no selectable leaves falls back to `__typename`.
254+
const buildDefaultSelectionSet = (
249255
ref: IntrospectionTypeRef,
250256
types: ReadonlyMap<string, IntrospectionType>,
251-
depth: number,
252-
seen: Set<string>,
253257
): string => {
254-
if (depth > MAX_SELECTION_DEPTH) return "";
255-
256-
const leafName = unwrapTypeName(ref);
257-
if (seen.has(leafName)) return "";
258-
259-
const objectType = types.get(leafName);
260-
if (!objectType?.fields) return "";
261-
262-
const kind = objectType.kind;
263-
if (kind === "SCALAR" || kind === "ENUM") return "";
264-
265-
seen.add(leafName);
266-
267-
const subFields: string[] = [];
268-
for (const f of objectType.fields) {
269-
if (subFields.length >= MAX_FIELDS_PER_LEVEL) break;
270-
if (f.name.startsWith("__")) continue;
271-
// Cannot synthesize a value for a required nested argument: skip the field
272-
// rather than emit an invalid selection.
273-
if (hasRequiredArgWithoutDefault(f)) continue;
274-
275-
if (isCompositeType(f.type, types)) {
276-
// A composite field MUST have a sub-selection. When the recursion yields
277-
// nothing (depth cap, cycle guard, or every child skipped) we drop the
278-
// field instead of emitting it bare (which the server rejects).
279-
const sub = buildSelectionSet(f.type, types, depth + 1, seen);
280-
if (sub) subFields.push(`${f.name} ${sub}`);
281-
} else {
282-
// Scalar / enum leaf: select it directly.
283-
subFields.push(f.name);
284-
}
285-
}
286-
287-
seen.delete(leafName);
288-
289-
return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : "";
258+
const objectType = types.get(unwrapTypeName(ref));
259+
if (!objectType?.fields) return ""; // scalar / enum / unknown: no selection
260+
if (objectType.kind === "SCALAR" || objectType.kind === "ENUM") return "";
261+
262+
const leaves = objectType.fields
263+
.filter(
264+
(f: IntrospectionField) =>
265+
!f.name.startsWith("__") &&
266+
!hasRequiredArgWithoutDefault(f) &&
267+
!isCompositeType(f.type, types),
268+
)
269+
.map((f: IntrospectionField) => f.name);
270+
271+
// A composite type MUST have a non-empty selection; `__typename` is a leaf
272+
// that exists on every composite, so it is a safe minimal fallback.
273+
return leaves.length > 0 ? `{ ${leaves.join(" ")} }` : "{ __typename }";
290274
};
291275

292276
// Name every generated operation: some servers reject anonymous operations, and
@@ -295,11 +279,23 @@ const buildSelectionSet = (
295279
const operationNameForField = (fieldName: string): string =>
296280
fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
297281

282+
interface BuiltOperation {
283+
/** The operation with the default (scalar-leaf) selection. */
284+
readonly operationString: string;
285+
/** Everything up to (not including) the field's selection set:
286+
* `query Op($a: T) { field(a: $a)`. A caller-supplied `select` is wrapped as
287+
* `{ <select> }` and spliced between this prefix and the suffix at invoke
288+
* time, so the selection can be chosen per call without re-introspecting. */
289+
readonly operationPrefix: string;
290+
/** Closes the operation: ` }`. */
291+
readonly operationSuffix: string;
292+
}
293+
298294
const buildOperationStringForField = (
299295
kind: GraphqlOperationKind,
300296
field: IntrospectionField,
301297
types: ReadonlyMap<string, IntrospectionType>,
302-
): string => {
298+
): BuiltOperation => {
303299
const opType = kind === "query" ? "query" : "mutation";
304300
const opName = operationNameForField(field.name);
305301

@@ -309,12 +305,16 @@ const buildOperationStringForField = (
309305
});
310306

311307
const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`);
312-
const selectionSet = buildSelectionSet(field.type, types, 0, new Set());
308+
const defaultSelection = buildDefaultSelectionSet(field.type, types);
313309

314310
const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : "";
315311
const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : "";
316312

317-
return `${opType} ${opName}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`;
313+
const operationPrefix = `${opType} ${opName}${varDefsStr} { ${field.name}${argPassStr}`;
314+
const operationSuffix = ` }`;
315+
const operationString = `${operationPrefix}${defaultSelection ? ` ${defaultSelection}` : ""}${operationSuffix}`;
316+
317+
return { operationString, operationPrefix, operationSuffix };
318318
};
319319

320320
interface PreparedOperation {
@@ -324,6 +324,28 @@ interface PreparedOperation {
324324
readonly binding: OperationBinding;
325325
}
326326

327+
// Surface an optional `select` input on every tool so a caller can choose the
328+
// return fields per call. The default operation selects only scalar leaves; to
329+
// fetch nested or list data the caller passes a GraphQL selection set here, which
330+
// is spliced into the operation at invoke time. A real field argument named
331+
// `select` (rare) is left untouched.
332+
const withSelectInput = (inputSchema: unknown, returnTypeName: string): unknown => {
333+
const base =
334+
inputSchema && typeof inputSchema === "object"
335+
? (inputSchema as Record<string, unknown>)
336+
: { type: "object", properties: {} };
337+
const properties = {
338+
...((base.properties as Record<string, unknown> | undefined) ?? {}),
339+
};
340+
if (!("select" in properties)) {
341+
properties.select = {
342+
type: "string",
343+
description: `Optional GraphQL selection set for the \`${returnTypeName}\` return type. Overrides the default, which selects only scalar fields. Provide the fields to return, with sub-selections for nested objects and arguments where required, e.g. "id name items { id title }". Omit for the default.`,
344+
};
345+
}
346+
return { ...base, type: "object", properties };
347+
};
348+
327349
const prepareOperations = (
328350
fields: readonly ExtractedField[],
329351
introspection: IntrospectionResult,
@@ -360,21 +382,31 @@ const prepareOperations = (
360382

361383
const key = `${extracted.kind}.${extracted.fieldName}`;
362384
const entry = fieldMap.get(key);
363-
const operationString = entry
385+
const built = entry
364386
? buildOperationStringForField(entry.kind, entry.field, typeMap)
365-
: `${extracted.kind} ${operationNameForField(extracted.fieldName)} { ${extracted.fieldName} }`;
387+
: {
388+
operationString: `${extracted.kind} ${operationNameForField(extracted.fieldName)} { ${extracted.fieldName} }`,
389+
operationPrefix: undefined,
390+
operationSuffix: undefined,
391+
};
366392

367393
const binding = OperationBinding.make({
368394
kind: extracted.kind,
369395
fieldName: extracted.fieldName,
370-
operationString,
396+
operationString: built.operationString,
371397
variableNames: extracted.arguments.map((a) => a.name),
398+
...(built.operationPrefix !== undefined && built.operationSuffix !== undefined
399+
? { operationPrefix: built.operationPrefix, operationSuffix: built.operationSuffix }
400+
: {}),
372401
});
373402

374403
return {
375404
toolName,
376405
description,
377-
inputSchema: Option.getOrUndefined(extracted.inputSchema),
406+
inputSchema: withSelectInput(
407+
Option.getOrUndefined(extracted.inputSchema),
408+
extracted.returnTypeName,
409+
),
378410
binding,
379411
};
380412
});

0 commit comments

Comments
 (0)