Skip to content
academia.sh

Lesson 29 / 34

Fragments and Variables

A fragment gathering a repeated selection set into one place, the resolver-call drop from merging fields in overlapping fragments, and variables fixing the number of distinct documents.

Contents

The engine has supported fragments and variables from the start, but what either one is good for was never shown. This lesson answers two questions by measuring them: what does writing a fragment buy, and what changes when a variable is used instead of inlining the value in the query text?

Both have something in common: neither changes the response. A fragmented query and an unfragmented one return the same data; a query with a variable and one with the value inlined return the same data. The gain is somewhere else.

This lesson’s parser, schema, data source, and engine are used exactly as they were written in the previous three lessons; only the engine’s selection-set-flattening portion will change.

What a Fragment Does

A query fragment is a named selection set bound to a type; this course calls it a fragment for short. A query that reads the same fields in more than one place writes that set once and calls it by name.

// run.mjs — compares an unfragmented query against a fragmented one
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { RESOLVERS, reset } from "./data.mjs";

// The same selection set repeated in three places
const UNFRAGMENTED = `{
  open:   loan(id: "O-1") { id status member { name } items { branch book { title } } }
  closed: loan(id: "O-3") { id status member { name } items { branch book { title } } }
  other:  loan(id: "O-2") { id status member { name } items { branch book { title } } }
}`;

// The same query, the selection set in one place
const FRAGMENTED = `{
  open:   loan(id: "O-1") { ...LoanSummary }
  closed: loan(id: "O-3") { ...LoanSummary }
  other:  loan(id: "O-2") { ...LoanSummary }
}
fragment LoanSummary on Loan { id status member { name } items { branch book { title } } }`;

// If a field like "status" had to change, how many lines would be touched?
const editSites = (text, field) => text.split("\n").filter((s) => s.includes(field)).length;

async function runAndCount(text, variables = {}) {
  const calls = [];
  reset();
  const s = await execute(SCHEMA, RESOLVERS, parse(text), { variables, tracer: (b) => calls.push(b) });
  return { data: JSON.stringify(s.data), calls: calls.length };
}

const a = await runAndCount(UNFRAGMENTED);
const b = await runAndCount(FRAGMENTED);

console.log("document       lines  lines with 'status'  resolver calls");
console.log(`unfragmented   ${String(UNFRAGMENTED.split("\n").length).padStart(5)}  ${String(editSites(UNFRAGMENTED, "status")).padStart(20)}  ${String(a.calls).padStart(14)}`);
console.log(`fragmented     ${String(FRAGMENTED.split("\n").length).padStart(5)}  ${String(editSites(FRAGMENTED, "status")).padStart(20)}  ${String(b.calls).padStart(14)}`);
console.log(`\nare the two documents' results identical: ${a.data === b.data}`);
console.log(`result: ${a.data.slice(0, 96)}...`);
document       lines  lines with 'status'  resolver calls
unfragmented       5                     3              30
fragmented         6                     1              30

are the two documents' results identical: true
result: {"open":{"id":"O-1","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"branch":"central...

The fragmented document is one line longer, but the selection set sits in a single place. When a field has to be added or removed, the unfragmented document requires touching three lines, the fragmented one, one. The difference is three to one, and it grows together with the number of root fields.

The results being identical and the resolver-call count not changing shows that the fragment disappears at run time. The engine opens the fragments while it flattens the selection set; past that point, there is no difference left between the fragmented and unfragmented document. A fragment is not an execution concept, it is a source-text concept.

What this buys is that a fragment can be matched to a component on the client side. In the component tree from the Component-Based Interface Development course, each component can declare its own data requirement as a fragment, and the parent component builds a single query by combining these fragments; when a child component adds a field, the parent query grows on its own.

Merging Overlapping Fields

While fragments are being combined, the same field can end up selected more than once. What happens if both fragments ask for id?

The engine’s selection-set-flattening portion is moved into a separate module, and it answers this question: fields that fall on the same response key are reduced to a single call, and their subselections merge.

// flatten.mjs — flattens the selection set; merges fields that fall on the same response key
import { possibleTypes } from "./schema.mjs";

// Opens fragments, drops branches that do not match the concrete type, lists the rest in order.
function open(schema, document, typeName, selection, acc = []) {
  for (const s of selection) {
    if (s.kind === "field") { acc.push(s); continue; }
    const fragment = s.kind === "spread" ? document.fragments[s.name] : s;
    const condition = s.kind === "spread" ? fragment.type : s.type;
    if (condition === typeName || possibleTypes(schema, condition).includes(typeName)) open(schema, document, typeName, fragment.selection, acc);
  }
  return acc;
}

// Fields that fall on the same response key are reduced to a single call; their subselections merge.
export function flatten(schema, document, typeName, selection, merge = true) {
  const opened = open(schema, document, typeName, selection);
  if (!merge) return opened;
  const map = new Map();
  for (const a of opened) {
    const existing = map.get(a.alias);
    if (!existing) { map.set(a.alias, { ...a, selection: a.selection ? [...a.selection] : null }); continue; }
    if (a.selection) existing.selection = [...(existing.selection ?? []), ...a.selection];
  }
  return [...map.values()];
}

The engine is rebuilt on top of this module. The resolver signature, the tracer, and level-ordered execution are as in the Resolvers lesson; two things change: the flattening work has been moved out, and merging can be switched off. The measurement will rest on this switch.

// executor.mjs — the engine, with its flattening step coming from a separate module
// Resolver signature, the tracer, and level-ordered execution are as in the Resolvers
// lesson; the selection-set flattening now comes from flatten.mjs and merging can be
// switched off.
import { parseType, fieldDef } from "./schema.mjs";
import { flatten } from "./flatten.mjs";

const LEAF = new Set(["scalar", "enum"]);

function resolveVariables(op, given) {
  const d = {};
  for (const b of op.variables) {
    const v = given[b.name] ?? b.defaultValue;
    if (v === undefined && b.type.endsWith("!")) throw new Error(`variable $${b.name} is required`);
    d[b.name] = v;
  }
  return d;
}

const resolveArgs = (args, vars) =>
  Object.fromEntries(Object.entries(args).map(([a, d]) => [a, d.kind === "variable" ? vars[d.name] : d.value]));

async function executeSelection(o, typeName, selection, source, path, sequential = false) {
  const fields = flatten(o.schema, o.document, typeName, selection, o.merge);
  const result = {};
  for (const f of fields) result[f.alias] = null;      // key order comes from the query
  const run = async (f) => { result[f.alias] = await executeField(o, typeName, f, source, `${path}.${f.alias}`); };
  if (sequential) for (const f of fields) await run(f);
  else await Promise.all(fields.map(run));
  return result;
}

async function executeField(o, typeName, field, source, path) {
  const def = fieldDef(o.schema.types[typeName], field.name);
  const custom = o.resolvers[typeName]?.[field.name];
  const resolver = custom ?? ((k) => k?.[field.name]);       // default resolver: reads the field of the same name
  const info = { field: field.name, parentType: typeName, path, isDefault: !custom };
  o.tracer?.(info);
  const value = await resolver(source, resolveArgs(field.args, o.vars), o.context, info);
  return complete(o, def.type, value, field.selection, path);
}

async function complete(o, typeText, value, selection, path) {
  if (value === null || value === undefined) return null;
  const t = parseType(typeText);
  if (t.list) return Promise.all(value.map((d, i) => complete(o, t.name, d, selection, `${path}[${i}]`)));
  const type = o.schema.types[t.name];
  if (LEAF.has(type.kind)) return value;
  const concrete = type.kind === "object" ? t.name : o.resolvers.__type[t.name](value);
  return executeSelection(o, concrete, selection, value, path);
}

export async function execute(schema, resolvers, document,
    { operationName, variables = {}, context = {}, tracer, merge = true } = {}) {
  const op = operationName ? document.operations.find((i) => i.name === operationName) : document.operations[0];
  const o = { schema, resolvers, document, vars: resolveVariables(op, variables), context, tracer, merge };
  const root = op.type === "mutation" ? schema.mutation : schema.query;
  return { data: await executeSelection(o, root, op.selection, null, "", op.type === "mutation") };
}
// overlap.mjs — measures overlapping fragments resolving the same field twice
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { RESOLVERS } from "./data.mjs";

const QUERY = `{
  loan(id: "O-1") { id ...Who ...What }
}
fragment Who on Loan { id status member { name } }
fragment What on Loan { id status items { branch } }`;

for (const merge of [false, true]) {
  const calls = [];
  const s = await execute(SCHEMA, RESOLVERS, parse(QUERY), { tracer: (b) => calls.push(b), merge });
  const counts = {};
  for (const b of calls) counts[`${b.parentType}.${b.field}`] = (counts[`${b.parentType}.${b.field}`] ?? 0) + 1;
  console.log(`merge ${merge ? "on " : "off"}  calls=${String(calls.length).padStart(2)}  ` +
    Object.entries(counts).map(([a, n]) => `${a}×${n}`).join(" "));
  console.log(`  result: ${JSON.stringify(s.data)}`);
}
merge off  calls=11  Query.loan×1 Loan.id×3 Loan.status×2 Loan.member×1 Loan.items×1 Member.name×1 Item.branch×2
  result: {"loan":{"id":"O-1","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"branch":"central"},{"branch":"shore"}]}}
merge on   calls= 8  Query.loan×1 Loan.id×1 Loan.status×1 Loan.member×1 Loan.items×1 Member.name×1 Item.branch×2
  result: {"loan":{"id":"O-1","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"branch":"central"},{"branch":"shore"}]}}

The two results are identical, and the call count dropped from eleven to eight. Without merging, the id field would resolve three times and status twice; they would still appear only once in the response, because they write to the same key. So execution without merging does not produce a wrong result, it does unnecessary work.

This is the rule that makes it safe to write a fragment per component. Five components can each ask for the id field in their own fragment; thanks to merging, the field resolves once. Without the rule, the cost of writing a fragment would be multiplied by the number of times it is used.

Merging has one condition: the fields falling on the same key must be able to overlap. A field asked for twice under the same name with two different arguments cannot be merged; in that case an alias is required, and two separate keys result.

Variables

A variable separates the query text from the value. The result of this separation is that the same document goes to the server every time the same screen opens.

// variables.mjs — compares a query with the value inlined in the text against one using a variable
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { RESOLVERS } from "./data.mjs";

const SMALL = ["O-1", "O-2", "O-3", "O-1", "O-2"];
// Large load: 200 requests, spread across 50 distinct ids
const LARGE = Array.from({ length: 200 }, (_, i) => `O-${(i % 50) + 1}`);

const INLINE       = { text: (id) => `{ loan(id: "${id}") { id status member { name } } }`, variables: () => ({}) };
const WITH_VARIABLE = { text: () => `query($id: ID!) { loan(id: $id) { id status member { name } } }`, variables: (id) => ({ id }) };

async function run(title, ids, format, shouldRun) {
  const cache = new Map();      // the server keys the parsed document by its text
  let misses = 0, bytes = 0;
  for (const id of ids) {
    const text = format.text(id);
    if (!cache.has(text)) { misses++; cache.set(text, parse(text)); }
    bytes += Buffer.byteLength(text) + Buffer.byteLength(JSON.stringify(format.variables(id)));
    if (shouldRun) await execute(SCHEMA, RESOLVERS, cache.get(text), { variables: format.variables(id) });
  }
  console.log(`${title.padEnd(32)} requests=${String(ids.length).padStart(3)}  distinct documents=${String(cache.size).padStart(3)}  ` +
    `parses=${String(misses).padStart(3)}  bytes sent=${bytes}`);
}

await run("5 requests, value inline in text", SMALL, INLINE, true);
await run("5 requests, with variable", SMALL, WITH_VARIABLE, true);
await run("200 requests, value inline in text", LARGE, INLINE, false);
await run("200 requests, with variable", LARGE, WITH_VARIABLE, false);
5 requests, value inline in text requests=  5  distinct documents=  3  parses=  3  bytes sent=255
5 requests, with variable        requests=  5  distinct documents=  1  parses=  1  bytes sent=375
200 requests, value inline in text requests=200  distinct documents= 50  parses= 50  bytes sent=10364
200 requests, with variable      requests=200  distinct documents=  1  parses=  1  bytes sent=15164

The bytes column works against the variable-based format, and it stays that way: declaring a variable lengthens the query text, and variables are sent separately. The gain shows up not in the byte count but in the distinct-document count.

At five requests the difference is three to one, at two hundred requests it is fifty to one. In the format that inlines the value, the distinct-document count grows together with the distinct-value count; in the variable-based format it is one, and it stays one. This number is useful in three places: the server can store the parsing and validation result per document, logs can group how often each query runs, and documents can be persisted in advance so the client only sends an identifier.

There is also a correctness reason. Inlining a value means injecting it into the query language; a value containing a quote, embedded without escaping, can change the query’s structure. When a variable is used, the value is never parsed as query text — it travels in a separate JSON body and its type is validated against the schema. The exact problem prepared statements solve in SQL is solved here in the same way.

Summary

  • A fragment is a named selection set bound to a type; it gathers repeated fields into one place, cutting the edit-site count from three to one.
  • A fragment disappears at run time: a fragmented and an unfragmented document produce the same result and the same resolver-call count; a fragment is a source-text concept.
  • Fields falling on the same response key are merged; execution without merging does not produce a wrong result, but it resolves the same field more than once.
  • Merging makes it safe to write a fragment per component; without it, the cost of writing a fragment would be multiplied by the number of times it is used.
  • Using a variable does not reduce the bytes sent, it fixes the distinct-document count: one document instead of fifty across two hundred requests.
  • A fixed document count makes it possible to store the parsing result, group logs, and persist documents in advance; and because the value never enters the query language, the structure cannot break.

Next Step

The Resolvers lesson left a number unaddressed: three member calls were made for three loan records, yet there were two distinct members. Fragments and variables did not touch this number; merging only removes collisions at the same response key, it does not remove different records asking for the same member. When the list grows to a hundred records, there will be a hundred member calls. The next lesson names this problem, writes a batch-loading layer that rests on the fact that calls at the same level are started together, and measures how far the round-trip count drops.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close