Merge pull request #2495 from DeusData/fix/issue-598-graphql-route-name

fix(pipeline): key GraphQL routes by operation name
This commit is contained in:
Martin Vogel
2026-10-04 16:23:32 +02:00
committed by GitHub
4 changed files with 308 additions and 25 deletions
+133
View File
@@ -870,6 +870,139 @@ bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *
return true;
}
/* ── GraphQL operation identity (#598) ─────────────────────────── */
static bool gql_name_start(unsigned char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_';
}
static bool gql_name_char(unsigned char c) {
return gql_name_start(c) || (c >= '0' && c <= '9');
}
/* Skip one lexical unit whose braces are not structure: a # comment, a "..."
* or """...""" string, or a JS ${...} template interpolation. Returns p
* unchanged when p starts none of them. */
static const char *gql_skip_opaque(const char *p) {
if (*p == '#') {
while (*p && *p != '\n' && *p != '\r') {
p++;
}
return p;
}
if (strncmp(p, "\"\"\"", 3) == 0) {
const char *end = strstr(p + 3, "\"\"\"");
return end ? end + 3 : p + strlen(p);
}
if (*p == '"') {
p++;
while (*p && *p != '"') {
p += (*p == '\\' && p[1]) ? 2 : 1;
}
return *p ? p + 1 : p;
}
if (p[0] == '$' && p[1] == '{') {
int depth = 1;
p += 2;
while (*p && depth > 0) {
depth += (*p == '{') - (*p == '}');
p++;
}
return p;
}
return p;
}
/* Skip GraphQL "ignored tokens" (whitespace, commas, comments). */
static const char *gql_skip_ignored(const char *p) {
for (;;) {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') {
p++;
}
if (*p != '#') {
return p;
}
p = gql_skip_opaque(p);
}
}
/* Copy the Name token at p (bounded). Returns false when p is not a Name. */
static bool gql_copy_name(const char *p, char *name_buf, size_t name_sz) {
if (!gql_name_start((unsigned char)*p) || !name_buf || name_sz == 0) {
return false;
}
size_t n = 0;
while (gql_name_char((unsigned char)p[n]) && n + 1 < name_sz) {
name_buf[n] = p[n];
n++;
}
name_buf[n] = '\0';
return true;
}
static const char *gql_operation_keyword(const char *tok, size_t len) {
static const char *const kws[] = {"query", "mutation", "subscription", NULL};
for (int i = 0; kws[i]; i++) {
if (strlen(kws[i]) == len && strncmp(tok, kws[i], len) == 0) {
return kws[i];
}
}
return NULL;
}
bool cbm_service_pattern_graphql_operation(const char *doc, const char **op_type, char *name_buf,
size_t name_sz) {
*op_type = "operation";
if (name_buf && name_sz > 0) {
name_buf[0] = '\0';
}
if (!doc) {
return false;
}
int depth = 0;
bool in_fragment = false;
const char *p = doc;
while (*p) {
const char *q = gql_skip_opaque(p);
if (q != p) {
p = q;
continue;
}
unsigned char c = (unsigned char)*p;
if (c == '{' && depth == 0 && !in_fragment) {
*op_type = "query"; /* `{ ... }` shorthand is an anonymous query */
return false;
}
if (c == '{' || c == '}') {
depth += (c == '{') ? 1 : -1;
depth = depth < 0 ? 0 : depth;
in_fragment = in_fragment && depth > 0;
p++;
continue;
}
if (depth > 0 || in_fragment || !gql_name_start(c)) {
p++;
continue;
}
const char *tok = p;
while (gql_name_char((unsigned char)*p)) {
p++;
}
size_t len = (size_t)(p - tok);
if (len == strlen("fragment") && strncmp(tok, "fragment", len) == 0) {
in_fragment = true;
continue;
}
const char *kw = gql_operation_keyword(tok, len);
if (!kw) {
return false; /* not a GraphQL document (URL, dict key, variable ...) */
}
*op_type = kw;
return gql_copy_name(gql_skip_ignored(p), name_buf, name_sz);
}
return false;
}
/* ── Public API ────────────────────────────────────────────────── */
/* Per-worker TLS cache of cbm_service_pattern_match results.
+14
View File
@@ -12,6 +12,7 @@
#define CBM_SERVICE_PATTERNS_H
#include <stdbool.h>
#include <stddef.h>
/* Edge type returned by pattern match. */
typedef enum {
@@ -77,4 +78,17 @@ const char *cbm_service_pattern_route_method(const char *callee_name);
* Returns NULL if not an async pattern. */
const char *cbm_service_pattern_broker(const char *resolved_qn);
/* GraphQL operation identity of a client call argument (#598).
* Scans `doc` as a GraphQL document (skipping comments, strings, `${...}`
* template interpolations and leading fragment definitions) and reports the
* first operation: *op_type is "query", "mutation" or "subscription" ("query"
* for the `{ ... }` shorthand). Returns true and writes the operation name to
* name_buf when the operation is named. Returns false for an anonymous
* operation, or with *op_type = "operation" when `doc` is not a GraphQL
* document (NULL, a URL, a bare word such as a dict key ...). The operation
* text itself is never copied, so callers can key Routes on a bounded
* identity. */
bool cbm_service_pattern_graphql_operation(const char *doc, const char **op_type, char *name_buf,
size_t name_sz);
#endif /* CBM_SERVICE_PATTERNS_H */
+28 -25
View File
@@ -2447,40 +2447,43 @@ static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "GRPC_CALLS", props);
}
/* Emit GRAPHQL_CALLS edge. Extract operation from first string arg if available. */
/* Emit GRAPHQL_CALLS edge. The Route is keyed by the operation NAME only
* (#598), never by the operation text:
* named operation -> __graphql__<Name> name "<Name>"
* anonymous query/mutation/ -> __graphql__<type>__anonymous name "(anonymous <type>)"
* subscription (incl. the `{ ... }` query shorthand)
* no GraphQL document -> __graphql__operation__anonymous name "(anonymous operation)"
* (URL, variable, missing string argument)
* Keys are bounded by the GraphQL Name grammar and deterministic. */
static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call,
const cbm_resolution_t *res) {
const char *op = call->first_string_arg;
if (!op || !op[0]) {
op = call->callee_name;
}
/* Try to extract a query/mutation name from the operation string */
char op_name[CBM_SZ_256];
snprintf(op_name, sizeof(op_name), "%s", op);
/* Trim leading whitespace and "query "/"mutation " prefix */
const char *p = op_name;
while (*p == ' ' || *p == '\t' || *p == '\n') {
p++;
}
if (strncmp(p, "query ", CBM_SZ_6) == 0) {
p += CBM_SZ_6;
} else if (strncmp(p, "mutation ", CBM_SZ_8) == 0) {
p += CBM_SZ_8;
const char *op_type = "operation";
char op_name[CBM_SZ_128];
bool named = cbm_service_pattern_graphql_operation(call->first_string_arg, &op_type, op_name,
sizeof(op_name));
char route_name[CBM_SZ_256];
char route_qn[CBM_SZ_256];
if (named) {
snprintf(route_name, sizeof(route_name), "%s", op_name);
snprintf(route_qn, sizeof(route_qn), "__graphql__%s", op_name);
} else {
snprintf(route_name, sizeof(route_name), "(anonymous %s)", op_type);
snprintf(route_qn, sizeof(route_qn), "__graphql__%s__anonymous", op_type);
}
char route_qn[CBM_SZ_512];
snprintf(route_qn, sizeof(route_qn), "__graphql__%s", p);
int64_t route_id =
cbm_gbuf_upsert_node(gbuf, "Route", p, route_qn, "", 0, 0, "{\"source\":\"graphql\"}");
int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", route_name, route_qn, "", 0, 0,
"{\"source\":\"graphql\"}");
char esc_c[CBM_SZ_256];
char esc_op[CBM_SZ_512];
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
cbm_json_escape(esc_op, sizeof(esc_op), p);
cbm_json_escape(esc_op, sizeof(esc_op), route_name);
char props[CBM_SZ_1K];
snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"operation\":\"%s\",\"confidence\":%.2f}",
esc_c, esc_op, res->confidence);
snprintf(props, sizeof(props),
"{\"callee\":\"%s\",\"operation\":\"%s\",\"operation_type\":\"%s\","
"\"confidence\":%.2f}",
esc_c, esc_op, op_type, res->confidence);
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "GRAPHQL_CALLS", props);
}
+133
View File
@@ -43,6 +43,7 @@
#include "test_framework.h"
#include "test_helpers.h"
#include "cbm.h"
#include "service_patterns.h"
#include <mcp/mcp.h>
#include <store/store.h>
#include <pipeline/pipeline.h>
@@ -244,6 +245,136 @@ static cbm_store_t *et_index_parallel(EtProj *lp, const EtFile *meaningful, int
return et_index_files(lp, files, n);
}
/* #598: a call that resolves to a GraphQL client used to name its Route after
* the WHOLE operation text (minus a leading "query "/"mutation "), so an
* anonymous `query { ... }` became one long Route and even a named operation
* carried its variables and selection set in the name. GraphQL Routes are now
* keyed by operation name only; anonymous operations share one bounded key per
* operation type. The Route set is asserted exactly (QN + name). Parallel path
* only: GRAPHQL_CALLS is emitted by pass_parallel.c. */
typedef struct {
const char *qn;
const char *name;
} EtGqlRoute;
static int et_graphql_routes_exact(const EtFile *files, int nfiles, const EtGqlRoute *want,
int nwant) {
EtProj lp;
cbm_store_t *store = et_index_parallel(&lp, files, nfiles);
cbm_node_t *nodes = NULL;
int node_count = 0;
int ok = store != NULL;
int got = 0;
if (!store || cbm_store_find_nodes_by_label(store, lp.project, "Route", &nodes, &node_count) !=
CBM_STORE_OK) {
ok = 0;
}
for (int ni = 0; nodes && ni < node_count; ni++) {
const char *qn = nodes[ni].qualified_name ? nodes[ni].qualified_name : "";
const char *name = nodes[ni].name ? nodes[ni].name : "";
if (strncmp(qn, "__graphql__", 11) != 0) {
continue;
}
got++;
int matched = 0;
for (int wi = 0; wi < nwant; wi++) {
if (strcmp(qn, want[wi].qn) == 0 && strcmp(name, want[wi].name) == 0) {
matched = 1;
break;
}
}
/* Operation text (selection sets, variables) must never leak into a key. */
if (!matched || strchr(qn, '{') || strchr(name, '{') || strlen(qn) > 160) {
fprintf(stderr, " [ET-GQL] unexpected Route qn='%s' name='%s'\n", qn, name);
ok = 0;
}
}
if (got != nwant) {
fprintf(stderr, " [ET-GQL] FAIL graphql routes expected=%d actual=%d\n", nwant, got);
ok = 0;
}
int edges = store ? cbm_store_count_edges_by_type(store, lp.project, "GRAPHQL_CALLS") : -1;
if (edges < nwant) {
fprintf(stderr, " [ET-GQL] FAIL GRAPHQL_CALLS=%d expected>=%d\n", edges, nwant);
ok = 0;
}
cbm_store_free_nodes(nodes, node_count);
et_cleanup(&lp, store);
return ok;
}
TEST(graphql_route_keyed_by_operation_name_issue598) {
static const EtFile f[] = {
{"graphql/client.py", "def gql(query):\n return query\n"},
{"api/ops.py",
"from graphql.client import gql\n\n\n"
"def get_user():\n"
" return gql('query GetUser($id: ID!) { user(id: $id) { name } }')\n\n\n"
"def update_user():\n"
" return gql('mutation UpdateUser($id: ID!) { updateUser(id: $id) { id } }')\n\n\n"
"def list_users():\n"
" return gql('fragment U on User { id } query ListUsers { users { ...U } }')\n\n\n"
"def on_event():\n"
" return gql('subscription OnEvent { event { id } }')\n\n\n"
"def products():\n"
" return gql('query { products(search: \"x\") { items { sku } } }')\n\n\n"
"def orders():\n"
" return gql('{ orders { id total } }')\n\n\n"
"def logout():\n"
" return gql('mutation { logout }')\n"}};
static const EtGqlRoute want[] = {
{"__graphql__GetUser", "GetUser"},
{"__graphql__UpdateUser", "UpdateUser"},
{"__graphql__ListUsers", "ListUsers"},
{"__graphql__OnEvent", "OnEvent"},
/* products + orders: `query { }` and the `{ }` shorthand are both
* anonymous queries and share one key. */
{"__graphql__query__anonymous", "(anonymous query)"},
{"__graphql__mutation__anonymous", "(anonymous mutation)"},
};
ASSERT_TRUE(et_graphql_routes_exact(f, (int)(sizeof(f) / sizeof(f[0])), want,
(int)(sizeof(want) / sizeof(want[0]))));
PASS();
}
/* #598 parser edges the end-to-end fixture cannot reach through one language's
* string extraction: comments/strings/interpolations that contain braces, a
* bare word, non-document arguments and name truncation. */
TEST(graphql_operation_identity_parser_issue598) {
const char *type = NULL;
char name[16];
static const struct {
const char *doc;
int named;
const char *type;
const char *name;
} cases[] = {
{"# fetch { all }\nquery Q1 { a }", 1, "query", "Q1"},
{"${UserFields} query WithFrag($id: ID) { u { ...UserFields } }", 1, "query", "WithFrag"},
{"fragment F on T @dir(x: \"{\") { a } mutation M2 { b }", 1, "mutation", "M2"},
{"\"\"\"doc { brace\"\"\" subscription S3 { c }", 1, "subscription", "S3"},
{" query \n ( $x: Int ) { a }", 0, "query", ""},
{" { viewer { id } }", 0, "query", ""},
{"query GetUserByIdentifier { a }", 1, "query", "GetUserByIdenti"}, /* bounded */
{"data", 0, "operation", ""}, /* a bare word is not evidence of an operation */
{"https://api.example.com/graphql", 0, "operation", ""},
{"/graphql", 0, "operation", ""},
{"", 0, "operation", ""},
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
bool named = cbm_service_pattern_graphql_operation(cases[i].doc, &type, name, sizeof(name));
if ((int)named != cases[i].named || strcmp(type, cases[i].type) != 0 ||
strcmp(name, cases[i].name) != 0) {
fprintf(stderr, " [ET-GQL] case %zu '%s' -> named=%d type=%s name=%s\n", i,
cases[i].doc, (int)named, type, name);
ASSERT_TRUE(0);
}
}
ASSERT_FALSE(cbm_service_pattern_graphql_operation(NULL, &type, name, sizeof(name)));
ASSERT_STR_EQ(type, "operation");
PASS();
}
/* #1085: count CALLS edges whose target node has `name`, indexing via the
* PARALLEL path (et_index_parallel pads to >50 files). The per-file TS resolver
* used to treat a raw tsconfig alias as a confident target. That false row made
@@ -1982,6 +2113,8 @@ TEST(routes_express_get_survives_xlang_guard_parallel) {
SUITE(edge_types_probe) {
/* HANDLES — route→handler across web frameworks */
RUN_TEST(calls_jsx_component_via_tsconfig_alias_parallel_issue1085);
RUN_TEST(graphql_route_keyed_by_operation_name_issue598);
RUN_TEST(graphql_operation_identity_parser_issue598);
RUN_TEST(handles_flask_python);
RUN_TEST(handles_fastapi_python);
RUN_TEST(handles_drf_action_python);