Skip to content

Commit cc94666

Browse files
authored
fix(graphql): generate valid operations against large schemas (UsefulSoftwareCo#1146) (UsefulSoftwareCo#1173)
* fix(graphql): generate valid operations against large schemas The selection-set builder emitted composite fields with no sub-selection (when it hit the depth cap or cycle guard) and selected nested fields whose required arguments it could not fill. Against a rich schema like GitLab's, every generated tool over a non-trivial return type failed server-side validation. buildSelectionSet now skips nested fields with a required argument it cannot supply, and drops composite fields whose sub-selection comes back empty instead of printing them bare. Adds a regression test that drives the plugin end to end against a GitLab-shaped graphql-js server. Fixes UsefulSoftwareCo#1146 * 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. * feat(graphql): validate a caller-supplied `select` locally before invoking graphql-js is already a runtime dependency, so parse-check the assembled operation when a `select` is provided and fail fast with a `graphql_invalid_selection` error instead of building a request. This rejects a malformed selection (and any attempt to break out of the field's selection set) with a precise local error, and never reaches the network for those. Field- and argument-level validity stays with the upstream server (which returns verbatim errors): a local GraphQLSchema would need a full introspection snapshot, but the stored snapshot is the plugin's reduced shape, which buildClientSchema cannot consume. The select-to-operation assembly is extracted to a shared `effectiveOperationString` so the validated string and the sent string are identical.
1 parent c67ae4b commit cc94666

7 files changed

Lines changed: 394 additions & 36 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@executor-js/plugin-graphql": patch
3+
---
4+
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: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,25 @@ export const endpointForTelemetry = (endpoint: string): string => {
2121
return url.toString();
2222
};
2323

24+
/** The operation string to send for a call. A caller-supplied `select` overrides
25+
* the default scalar-leaf selection: it is spliced into the field's selection
26+
* set (`field { <select> }`) so nested/list data can be requested per call. Falls
27+
* back to the stored default operation when `select` is absent or the binding
28+
* predates the prefix/suffix split. `select` is a control input, never a GraphQL
29+
* variable. Shared with plugin.invokeTool so the validated string matches the
30+
* sent string exactly. */
31+
export const effectiveOperationString = (
32+
operation: OperationBinding,
33+
args: Record<string, unknown>,
34+
): string => {
35+
const customSelect = typeof args.select === "string" ? args.select.trim() : "";
36+
return customSelect.length > 0 &&
37+
operation.operationPrefix != null &&
38+
operation.operationSuffix != null
39+
? `${operation.operationPrefix} { ${customSelect} }${operation.operationSuffix}`
40+
: operation.operationString;
41+
};
42+
2443
// ---------------------------------------------------------------------------
2544
// Response helpers
2645
// ---------------------------------------------------------------------------
@@ -71,10 +90,15 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* (
7190
Object.assign(variables, args.variables);
7291
}
7392

93+
// `select` (a control input, not a GraphQL variable) is applied here and never
94+
// enters `variables`. plugin.invokeTool validates this same string before we
95+
// reach the network.
96+
const operationString = effectiveOperationString(operation, args);
97+
7498
let request = HttpClientRequest.post(requestEndpoint).pipe(
7599
HttpClientRequest.setHeader("Content-Type", "application/json"),
76100
HttpClientRequest.bodyJsonUnsafe({
77-
query: operation.operationString,
101+
query: operationString,
78102
variables: Object.keys(variables).length > 0 ? variables : undefined,
79103
}),
80104
);

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { endpointForTelemetry } from "./invoke";
2828
import { introspect } from "./introspect";
2929
import type { IntrospectionResult } from "./introspect";
3030
import {
31+
makeGitlab1146Schema,
3132
makeGreetingGraphqlSchema,
3233
serveGraphqlFailureTestServer,
3334
serveGraphqlTestServer,
@@ -885,3 +886,132 @@ describe("graphqlPlugin detect URL-token fallback", () => {
885886
}),
886887
);
887888
});
889+
890+
// Issue #1146: against a large, real-world schema (GitLab) the auto-generated
891+
// operations were invalid GraphQL and every call against a rich object type
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.
898+
describe("graphqlPlugin generates valid operations against rich schemas (#1146)", () => {
899+
const gitlabServer = serveGraphqlTestServer({ schema: makeGitlab1146Schema() });
900+
901+
const lastQuery = (requests: { readonly payload: { readonly query?: string } }[]): string =>
902+
requests[requests.length - 1]?.payload.query ?? "";
903+
904+
const setup = (slug: string) =>
905+
Effect.gen(function* () {
906+
const server = yield* gitlabServer;
907+
const executor = yield* makeExecutor();
908+
yield* executor.graphql.addIntegration({ endpoint: server.endpoint, slug });
909+
yield* createOrgConnection(executor, {
910+
integration: slug,
911+
name: "main",
912+
template: "none",
913+
value: "unused",
914+
});
915+
yield* server.clearRequests;
916+
return { server, executor };
917+
});
918+
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.
936+
yield* server.clearRequests;
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+
);
956+
957+
expect(result).toMatchObject({ ok: true });
958+
const query = lastQuery(yield* server.requests);
959+
expect(query).toContain("nodes {");
960+
expect(query).toContain("author {");
961+
}),
962+
);
963+
964+
it.effect("`select` can supply a nested field's required argument", () =>
965+
Effect.gen(function* () {
966+
const { server, executor } = yield* setup("gitlab_ff");
967+
968+
const result = yield* executor.execute(toolAddr("gitlab_ff", "main", "query.metadata"), {
969+
select: 'version featureFlags(names: ["flag_a"]) { name enabled }',
970+
});
971+
972+
expect(result).toMatchObject({ ok: true });
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+
});
992+
}),
993+
);
994+
995+
it.effect("rejects a malformed `select` locally, before any network call", () =>
996+
Effect.gen(function* () {
997+
const { server, executor } = yield* setup("gitlab_syntax");
998+
// Warm the binding cache (the first invoke introspects to materialize
999+
// bindings) so the malformed call below has no legitimate reason to hit the
1000+
// wire: if it does, validation failed to short-circuit.
1001+
yield* executor.execute(toolAddr("gitlab_syntax", "main", "query.currentUser"), {});
1002+
yield* server.clearRequests;
1003+
1004+
const result = yield* executor.execute(
1005+
toolAddr("gitlab_syntax", "main", "query.currentUser"),
1006+
{ select: "active mergeRequests { nodes {" },
1007+
);
1008+
1009+
expect(result).toMatchObject({
1010+
ok: false,
1011+
error: { code: "graphql_invalid_selection" },
1012+
});
1013+
// Parse-check happens before the request is built, so nothing reached the server.
1014+
expect((yield* server.requests).length).toBe(0);
1015+
}),
1016+
);
1017+
});

0 commit comments

Comments
 (0)