Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · GraphQL

GraphQL API with NestJS Demo

A GraphQL server running in your browser, with a parser, validator and executor written for this demo. Pick an example or write your own operation, switch the logged-in viewer, and press Compare to run the same query with and without DataLoader. The counters and the SQL list show the N+1 problem and how batching fixes it.

Read the case study →

Interactive demo

samiullah.tech/lab/nestjs-graphql-api
–
SQL queries
–
resolver calls
–
loader batches
–
depth (max 6)

Press Run to execute the operation against the in-browser server.

server log

  1. $ waiting for requests…
type Query {
  """Recipes, oldest first, with cursor pagination (Relay connection)."""
  recipes(first: Int = 10, after: String, filter: RecipeFilter): RecipeConnection!
  recipe(id: ID!): Recipe
  authors: [Author!]!
  author(id: ID!): Author
  """The logged-in author, from the JWT in the request context."""
  me: Author
}

type Mutation {
  createRecipe(input: CreateRecipeInput!): Recipe!
  """Only the recipe's author may update it."""
  updateRecipe(id: ID!, input: UpdateRecipeInput!): Recipe!
  addReview(recipeId: ID!, rating: Int!, body: String): Review!
  deleteRecipe(id: ID!): Boolean!
}

type Author {
  id: ID!
  name: String!
  bio: String
  recipes: [Recipe!]!
}

type Recipe {
  id: ID!
  title: String!
  """Total time in minutes."""
  minutes: Int!
  tags: [String!]!
  createdAt: String!
  author: Author!
  ingredients: [Ingredient!]!
  reviews: [Review!]!
  """Mean rating, null when there are no reviews. Shares the reviews loader, so it costs no extra query."""
  averageRating: Float
}

type Ingredient {
  id: ID!
  name: String!
}

type Review {
  id: ID!
  rating: Int!
  body: String
  author: Author!
  recipe: Recipe!
}

type RecipeConnection {
  edges: [RecipeEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type RecipeEdge {
  cursor: String!
  node: Recipe!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

input RecipeFilter {
  tag: String
  maxMinutes: Int
  search: String
}

input CreateRecipeInput {
  title: String!
  minutes: Int!
  tags: [String!]
  ingredientIds: [ID!]!
}

input UpdateRecipeInput {
  title: String
  minutes: Int
  tags: [String!]
}

## How it works

parser.ts turns your text into an AST. execute.ts picks the operation, coerces variables, validates every field and argument against the schema, rejects queries deeper than six levels, and then runs the resolvers: query fields in parallel, mutation fields one after another, with GraphQL's null propagation for non-null fields.

Relation fields such as Recipe.author either call the repository directly (one SQL statement per parent row: the N+1 problem) or go through a per-request DataLoader. The loader collects every key requested during one tick and fetches them with a single WHERE id = ANY(…) query.

Mutations check the viewer: anonymous calls get UNAUTHENTICATED, and editing a recipe you did not write gets FORBIDDEN. The schema tab is printed from the same code-first definitions the resolvers use, so the two cannot drift.

## View source

The code running in the window above, exactly as deployed.

app/lab/nestjs-graphql-api/schema.ts307 lines
/**
 * The code-first schema: object types, inputs and resolvers in one place, the way @nestjs/graphql builds the
 * schema from @ObjectType() classes and @Resolver() methods. printSchema() turns it into the SDL shown in the demo.
 *
 * Relation fields (Recipe.author, Recipe.reviews, ...) read through per-request DataLoaders when they are enabled,
 * and straight from the repository when they are not. That switch is the whole N+1 comparison.
 */
import { GraphQLError } from "./parser";
import type { Author, Db, Recipe, Repo, Review } from "./data";

/* ------------------------------------------------------------ DataLoader */

/**
 * The DataLoader pattern in 30 lines: load(key) queues the key and returns a promise; once the current
 * tick of resolvers has run, one batch call fetches every queued key. Results are cached for the request.
 */
export class DataLoader<K, V> {
  private cache = new Map<K, Promise<V>>();
  private queue: { key: K; resolve: (v: V) => void; reject: (e: unknown) => void }[] = [];
  constructor(
    private readonly batch: (keys: readonly K[]) => V[],
    private readonly onDispatch: (size: number) => void,
  ) {}

  load(key: K): Promise<V> {
    const hit = this.cache.get(key);
    if (hit) return hit;
    const p = new Promise<V>((resolve, reject) => {
      this.queue.push({ key, resolve, reject });
      // setTimeout(0) runs after every pending microtask, so a whole level of the query lands in one batch.
      if (this.queue.length === 1) setTimeout(() => this.dispatch(), 0);
    });
    this.cache.set(key, p);
    return p;
  }

  private dispatch() {
    const q = this.queue;
    this.queue = [];
    this.onDispatch(q.length);
    try {
      const values = this.batch(q.map((x) => x.key));
      q.forEach((x, i) => x.resolve(values[i]!));
    } catch (e) {
      q.forEach((x) => x.reject(e));
    }
  }
}

export type Stats = { resolverCalls: number; batches: { loader: string; size: number }[] };

export function createLoaders(repo: Repo, stats: Stats) {
  const mk = <V,>(name: string, fn: (keys: readonly number[]) => V[]) =>
    new DataLoader<number, V>(fn, (size) => stats.batches.push({ loader: name, size }));
  return {
    author: mk("authorById", (k) => repo.authorsByIds(k)),
    recipesByAuthor: mk("recipesByAuthor", (k) => repo.recipesByAuthors(k)),
    ingredientsByRecipe: mk("ingredientsByRecipe", (k) => repo.ingredientsByRecipes(k)),
    reviewsByRecipe: mk("reviewsByRecipe", (k) => repo.reviewsByRecipes(k)),
  };
}
export type Loaders = ReturnType<typeof createLoaders>;

/* ---------------------------------------------------------------- schema */

export type Ctx = { db: Db; repo: Repo; viewer: Author | null; loaders: Loaders | null; stats: Stats };
export type Args = Record<string, unknown>;
export type ArgDef = { type: string; defaultValue?: unknown; description?: string };
export type FieldDef = {
  type: string;
  description?: string;
  args?: Record<string, ArgDef>;
  resolve?: (parent: unknown, args: Args, ctx: Ctx) => unknown;
};
export type ObjectType = { name: string; description?: string; fields: Record<string, FieldDef> };
export type InputType = { name: string; fields: Record<string, ArgDef> };

/** Typed helper so each resolver sees its parent type, like @Parent() recipe: Recipe in Nest. */
function field<P>(d: Omit<FieldDef, "resolve"> & { resolve?: (parent: P, args: Args, ctx: Ctx) => unknown }): FieldDef {
  return d as unknown as FieldDef;
}

const bad = (msg: string) => new GraphQLError(msg, { code: "BAD_USER_INPUT" });
const requireViewer = (ctx: Ctx) => {
  if (!ctx.viewer) throw new GraphQLError("You must be logged in.", { code: "UNAUTHENTICATED" });
  return ctx.viewer;
};
const encodeCursor = (id: number) => btoa(`Recipe:${id}`);
const decodeCursor = (c: string) => {
  try {
    const m = /^Recipe:(\d+)$/.exec(atob(c));
    if (m) return Number(m[1]);
  } catch {
    /* fall through */
  }
  throw bad(`Invalid cursor "${c}".`);
};

const authorOf = (id: number, ctx: Ctx) => (ctx.loaders ? ctx.loaders.author.load(id) : ctx.repo.authorById(id));
const reviewsOf = (id: number, ctx: Ctx) => (ctx.loaders ? ctx.loaders.reviewsByRecipe.load(id) : ctx.repo.reviewsByRecipe(id));

function checkRecipeInput(input: Args, partial: boolean) {
  if (input.title !== undefined && input.title !== null) {
    const t = String(input.title).trim();
    if (t.length < 3 || t.length > 80) throw bad("title must be 3–80 characters.");
  } else if (!partial) throw bad("title is required.");
  if (input.minutes !== undefined && input.minutes !== null && ((input.minutes as number) < 1 || (input.minutes as number) > 1440)) {
    throw bad("minutes must be between 1 and 1440.");
  }
}

export const types: Record<string, ObjectType> = {
  Query: {
    name: "Query",
    fields: {
      recipes: field<null>({
        type: "RecipeConnection!",
        description: "Recipes, oldest first, with cursor pagination (Relay connection).",
        args: { first: { type: "Int", defaultValue: 10 }, after: { type: "String" }, filter: { type: "RecipeFilter" } },
        resolve: (_p, a, ctx) => {
          const first = a.first as number;
          if (first < 1 || first > 50) throw bad("first must be between 1 and 50.");
          const after = a.after ? decodeCursor(String(a.after)) : 0;
          const f = (a.filter ?? {}) as { tag?: string; maxMinutes?: number; search?: string };
          const { rows, total } = ctx.repo.recipesPage({ tag: f.tag ?? undefined, maxMinutes: f.maxMinutes ?? undefined, search: f.search ?? undefined }, after, first);
          const page = rows.slice(0, first);
          return {
            edges: page.map((r) => ({ cursor: encodeCursor(r.id), node: r })),
            pageInfo: { hasNextPage: rows.length > first, endCursor: page.length ? encodeCursor(page.at(-1)!.id) : null },
            totalCount: total,
          };
        },
      }),
      recipe: field<null>({ type: "Recipe", args: { id: { type: "ID!" } }, resolve: (_p, a, ctx) => ctx.repo.recipeById(Number(a.id)) }),
      authors: field<null>({ type: "[Author!]!", resolve: (_p, _a, ctx) => ctx.repo.allAuthors() }),
      author: field<null>({ type: "Author", args: { id: { type: "ID!" } }, resolve: (_p, a, ctx) => authorOf(Number(a.id), ctx) }),
      me: field<null>({ type: "Author", description: "The logged-in author, from the JWT in the request context.", resolve: (_p, _a, ctx) => ctx.viewer }),
    },
  },
  Mutation: {
    name: "Mutation",
    fields: {
      createRecipe: field<null>({
        type: "Recipe!",
        args: { input: { type: "CreateRecipeInput!" } },
        resolve: (_p, a, ctx) => {
          const viewer = requireViewer(ctx);
          const input = a.input as { title: string; minutes: number; tags?: string[] | null; ingredientIds: string[] };
          checkRecipeInput(input, false);
          const ids = input.ingredientIds.map(Number);
          const unknown = ids.filter((id) => !ctx.db.ingredients.some((i) => i.id === id));
          if (unknown.length) throw bad(`Unknown ingredient id(s): ${unknown.join(", ")}.`);
          const r: Recipe = {
            id: Math.max(0, ...ctx.db.recipes.map((x) => x.id)) + 1,
            title: input.title.trim(),
            minutes: input.minutes,
            tags: input.tags ?? [],
            authorId: viewer.id,
            createdAt: new Date().toISOString(),
          };
          ctx.db.recipes.push(r);
          ctx.repo.exec(`INSERT INTO recipes (title, minutes, tags, author_id) VALUES ('${r.title}', ${r.minutes}, '{${r.tags.join(",")}}', ${viewer.id}) RETURNING *`);
          for (const id of new Set(ids)) ctx.db.recipeIngredients.push({ recipeId: r.id, ingredientId: id });
          ctx.repo.exec(`INSERT INTO recipe_ingredients (recipe_id, ingredient_id) SELECT ${r.id}, unnest('{${[...new Set(ids)].join(",")}}'::int[])`);
          return r;
        },
      }),
      updateRecipe: field<null>({
        type: "Recipe!",
        description: "Only the recipe's author may update it.",
        args: { id: { type: "ID!" }, input: { type: "UpdateRecipeInput!" } },
        resolve: (_p, a, ctx) => {
          const viewer = requireViewer(ctx);
          const r = ctx.repo.recipeById(Number(a.id));
          if (!r) throw new GraphQLError(`Recipe ${String(a.id)} not found.`, { code: "NOT_FOUND" });
          if (r.authorId !== viewer.id) throw new GraphQLError("Only the author can update this recipe.", { code: "FORBIDDEN" });
          const input = a.input as Partial<Pick<Recipe, "title" | "minutes" | "tags">>;
          checkRecipeInput(input, true);
          if (input.title != null) r.title = input.title.trim();
          if (input.minutes != null) r.minutes = input.minutes;
          if (input.tags != null) r.tags = input.tags;
          ctx.repo.exec(`UPDATE recipes SET ${Object.keys(input).map((k) => `${k} = $`).join(", ")} WHERE id = ${r.id} AND author_id = ${viewer.id}`);
          return r;
        },
      }),
      addReview: field<null>({
        type: "Review!",
        args: { recipeId: { type: "ID!" }, rating: { type: "Int!" }, body: { type: "String" } },
        resolve: (_p, a, ctx) => {
          const viewer = requireViewer(ctx);
          const rating = a.rating as number;
          if (rating < 1 || rating > 5) throw bad("rating must be between 1 and 5.");
          const r = ctx.repo.recipeById(Number(a.recipeId));
          if (!r) throw new GraphQLError(`Recipe ${String(a.recipeId)} not found.`, { code: "NOT_FOUND" });
          if (r.authorId === viewer.id) throw new GraphQLError("You cannot review your own recipe.", { code: "FORBIDDEN" });
          const review: Review = { id: Math.max(0, ...ctx.db.reviews.map((x) => x.id)) + 1, recipeId: r.id, authorId: viewer.id, rating, body: (a.body as string | null) ?? null };
          ctx.db.reviews.push(review);
          ctx.repo.exec(`INSERT INTO reviews (recipe_id, author_id, rating, body) VALUES (${r.id}, ${viewer.id}, ${rating}, $1) RETURNING *`);
          return review;
        },
      }),
      deleteRecipe: field<null>({
        type: "Boolean!",
        args: { id: { type: "ID!" } },
        resolve: (_p, a, ctx) => {
          const viewer = requireViewer(ctx);
          const r = ctx.repo.recipeById(Number(a.id));
          if (!r) throw new GraphQLError(`Recipe ${String(a.id)} not found.`, { code: "NOT_FOUND" });
          if (r.authorId !== viewer.id) throw new GraphQLError("Only the author can delete this recipe.", { code: "FORBIDDEN" });
          ctx.db.recipes = ctx.db.recipes.filter((x) => x.id !== r.id);
          ctx.db.reviews = ctx.db.reviews.filter((x) => x.recipeId !== r.id);
          ctx.db.recipeIngredients = ctx.db.recipeIngredients.filter((x) => x.recipeId !== r.id);
          ctx.repo.exec(`DELETE FROM recipes WHERE id = ${r.id} -- reviews and recipe_ingredients cascade`);
          return true;
        },
      }),
    },
  },
  Author: {
    name: "Author",
    fields: {
      id: { type: "ID!" },
      name: { type: "String!" },
      bio: { type: "String" },
      recipes: field<Author>({
        type: "[Recipe!]!",
        resolve: (a, _x, ctx) => (ctx.loaders ? ctx.loaders.recipesByAuthor.load(a.id) : ctx.repo.recipesByAuthor(a.id)),
      }),
    },
  },
  Recipe: {
    name: "Recipe",
    fields: {
      id: { type: "ID!" },
      title: { type: "String!" },
      minutes: { type: "Int!", description: "Total time in minutes." },
      tags: { type: "[String!]!" },
      createdAt: { type: "String!" },
      author: field<Recipe>({ type: "Author!", resolve: (r, _a, ctx) => authorOf(r.authorId, ctx) }),
      ingredients: field<Recipe>({
        type: "[Ingredient!]!",
        resolve: (r, _a, ctx) => (ctx.loaders ? ctx.loaders.ingredientsByRecipe.load(r.id) : ctx.repo.ingredientsByRecipe(r.id)),
      }),
      reviews: field<Recipe>({ type: "[Review!]!", resolve: (r, _a, ctx) => reviewsOf(r.id, ctx) }),
      averageRating: field<Recipe>({
        type: "Float",
        description: "Mean rating, null when there are no reviews. Shares the reviews loader, so it costs no extra query.",
        resolve: async (r, _a, ctx) => {
          const list = await reviewsOf(r.id, ctx);
          return list.length ? Math.round((list.reduce((s, x) => s + x.rating, 0) / list.length) * 10) / 10 : null;
        },
      }),
    },
  },
  Ingredient: { name: "Ingredient", fields: { id: { type: "ID!" }, name: { type: "String!" } } },
  Review: {
    name: "Review",
    fields: {
      id: { type: "ID!" },
      rating: { type: "Int!" },
      body: { type: "String" },
      author: field<Review>({ type: "Author!", resolve: (r, _a, ctx) => authorOf(r.authorId, ctx) }),
      recipe: field<Review>({ type: "Recipe!", resolve: (r, _a, ctx) => ctx.repo.recipeById(r.recipeId) }),
    },
  },
  RecipeConnection: {
    name: "RecipeConnection",
    fields: { edges: { type: "[RecipeEdge!]!" }, pageInfo: { type: "PageInfo!" }, totalCount: { type: "Int!" } },
  },
  RecipeEdge: { name: "RecipeEdge", fields: { cursor: { type: "String!" }, node: { type: "Recipe!" } } },
  PageInfo: { name: "PageInfo", fields: { hasNextPage: { type: "Boolean!" }, endCursor: { type: "String" } } },
};

export const inputs: Record<string, InputType> = {
  RecipeFilter: { name: "RecipeFilter", fields: { tag: { type: "String" }, maxMinutes: { type: "Int" }, search: { type: "String" } } },
  CreateRecipeInput: {
    name: "CreateRecipeInput",
    fields: { title: { type: "String!" }, minutes: { type: "Int!" }, tags: { type: "[String!]" }, ingredientIds: { type: "[ID!]!" } },
  },
  UpdateRecipeInput: { name: "UpdateRecipeInput", fields: { title: { type: "String" }, minutes: { type: "Int" }, tags: { type: "[String!]" } } },
};

export const SCALARS = new Set(["ID", "String", "Int", "Float", "Boolean"]);

/** Print the schema as SDL, like the autoSchemaFile Nest writes on start-up. */
export function printSchema(): string {
  const lit = (v: unknown) => JSON.stringify(v);
  const desc = (d: string | undefined, indent: string) => (d ? `${indent}"""${d}"""\n` : "");
  const argList = (args?: Record<string, ArgDef>) =>
    args && Object.keys(args).length
      ? `(${Object.entries(args)
          .map(([n, a]) => `${n}: ${a.type}${a.defaultValue !== undefined ? ` = ${lit(a.defaultValue)}` : ""}`)
          .join(", ")})`
      : "";
  const out: string[] = [];
  for (const t of Object.values(types)) {
    const body = Object.entries(t.fields)
      .map(([n, f]) => `${desc(f.description, "  ")}  ${n}${argList(f.args)}: ${f.type}`)
      .join("\n");
    out.push(`${desc(t.description, "")}type ${t.name} {\n${body}\n}`);
  }
  for (const i of Object.values(inputs)) {
    out.push(`input ${i.name} {\n${Object.entries(i.fields).map(([n, f]) => `  ${n}: ${f.type}`).join("\n")}\n}`);
  }
  return out.join("\n\n");
}
app/lab/nestjs-graphql-api/execute.ts293 lines
/**
 * Validation and execution for the recipe schema, following the GraphQL spec's algorithm:
 * parse → pick operation → coerce variables → validate (fields, arguments, leaf selections, depth limit)
 * → execute (queries in parallel, mutations in series) → complete values with null propagation.
 */
import { GraphQLError, parse, type DocumentNode, type FieldNode, type SelectionNode, type ValueNode } from "./parser";
import { SCALARS, inputs, types, type ArgDef, type Args, type Ctx, type FieldDef, type ObjectType } from "./schema";

export const MAX_DEPTH = 6;

type TypeNode = { kind: "NonNull"; of: TypeNode } | { kind: "List"; of: TypeNode } | { kind: "Named"; name: string };

export function parseType(t: string): TypeNode {
  if (t.endsWith("!")) return { kind: "NonNull", of: parseType(t.slice(0, -1)) };
  if (t.startsWith("[")) return { kind: "List", of: parseType(t.slice(1, -1)) };
  return { kind: "Named", name: t };
}
const namedType = (t: TypeNode): string => (t.kind === "Named" ? t.name : namedType(t.of));

/* ------------------------------------------------------------- coercion */

function coerceInput(t: TypeNode, v: unknown, where: string): unknown {
  if (t.kind === "NonNull") {
    if (v === null || v === undefined) throw new GraphQLError(`${where}: expected non-null value.`, { code: "BAD_USER_INPUT" });
    return coerceInput(t.of, v, where);
  }
  if (v === null || v === undefined) return null;
  if (t.kind === "List") return (Array.isArray(v) ? v : [v]).map((x, i) => coerceInput(t.of, x, `${where}[${i}]`));
  const n = t.name;
  const fail = () => {
    throw new GraphQLError(`${where}: ${n} cannot represent ${JSON.stringify(v)}.`, { code: "BAD_USER_INPUT" });
  };
  if (n === "Int") return Number.isInteger(v) && Math.abs(v as number) < 2 ** 31 ? v : fail();
  if (n === "Float") return typeof v === "number" && Number.isFinite(v) ? v : fail();
  if (n === "String") return typeof v === "string" ? v : fail();
  if (n === "Boolean") return typeof v === "boolean" ? v : fail();
  if (n === "ID") return typeof v === "string" || Number.isInteger(v) ? String(v) : fail();
  const input = inputs[n];
  if (!input) throw new GraphQLError(`Unknown type "${n}".`);
  if (typeof v !== "object" || Array.isArray(v)) return fail();
  const obj = v as Record<string, unknown>;
  for (const k of Object.keys(obj)) {
    if (!input.fields[k]) throw new GraphQLError(`${where}: field "${k}" is not defined by type "${n}".`, { code: "BAD_USER_INPUT" });
  }
  const out: Record<string, unknown> = {};
  for (const [k, def] of Object.entries(input.fields)) {
    const val = obj[k] === undefined ? def.defaultValue : obj[k];
    if (val === undefined && def.type.endsWith("!")) {
      throw new GraphQLError(`${where}: field "${n}.${k}" of required type "${def.type}" was not provided.`, { code: "BAD_USER_INPUT" });
    }
    if (val !== undefined) out[k] = coerceInput(parseType(def.type), val, `${where}.${k}`);
  }
  return out;
}

function valueFromAst(v: ValueNode, vars: Record<string, unknown>): unknown {
  switch (v.kind) {
    case "Variable":
      return vars[v.name];
    case "Null":
      return null;
    case "List":
      return v.values.map((x) => valueFromAst(x, vars));
    case "Object":
      return Object.fromEntries(Object.entries(v.fields).map(([k, x]) => [k, valueFromAst(x, vars)]));
    case "Enum":
      throw new GraphQLError(`Enum value "${v.value}" is not valid here; this schema has no enums. Did you forget quotes?`, { code: "BAD_USER_INPUT" });
    default:
      return v.value;
  }
}

function coerceArgs(parentType: string, fieldName: string, def: FieldDef, node: FieldNode, vars: Record<string, unknown>): Args {
  const out: Args = {};
  const defs: Record<string, ArgDef> = def.args ?? {};
  for (const [name, a] of Object.entries(defs)) {
    const ast = node.args[name];
    let v = ast ? valueFromAst(ast, vars) : undefined;
    if (v === undefined) v = a.defaultValue;
    if (v === undefined && a.type.endsWith("!")) {
      throw new GraphQLError(`Field "${parentType}.${fieldName}" argument "${name}" of type "${a.type}" is required, but it was not provided.`, {
        locations: [node.loc],
      });
    }
    if (v !== undefined) out[name] = coerceInput(parseType(a.type), v, `Argument "${name}"`);
  }
  return out;
}

/* ----------------------------------------------------------- validation */

function collect(typeName: string, sels: SelectionNode[], doc: DocumentNode, errors: GraphQLError[], seen = new Set<string>()): FieldNode[] {
  const out: FieldNode[] = [];
  for (const s of sels) {
    if (s.kind === "Field") out.push(s);
    else if (s.kind === "InlineFragment") {
      if (s.typeCondition && s.typeCondition !== typeName) {
        errors.push(new GraphQLError(`Fragment cannot be spread here as objects of type "${typeName}" can never be of type "${s.typeCondition}".`, { locations: [s.loc] }));
      } else out.push(...collect(typeName, s.selections, doc, errors, seen));
    } else {
      const f = doc.fragments[s.name];
      if (!f) errors.push(new GraphQLError(`Unknown fragment "${s.name}".`, { locations: [s.loc] }));
      else if (seen.has(s.name)) errors.push(new GraphQLError(`Cannot spread fragment "${s.name}" within itself.`, { locations: [s.loc] }));
      else if (f.typeCondition !== typeName) {
        errors.push(new GraphQLError(`Fragment "${s.name}" cannot be spread here as objects of type "${typeName}" can never be of type "${f.typeCondition}".`, { locations: [s.loc] }));
      } else out.push(...collect(typeName, f.selections, doc, errors, new Set([...seen, s.name])));
    }
  }
  return out;
}

/** Walks the operation against the schema. Returns the deepest field nesting it found. */
function validateSelections(type: ObjectType, sels: SelectionNode[], doc: DocumentNode, errors: GraphQLError[], depth: number): number {
  let max = depth;
  for (const f of collect(type.name, sels, doc, errors)) {
    if (f.name === "__typename") continue;
    const def = type.fields[f.name];
    if (!def) {
      errors.push(new GraphQLError(`Cannot query field "${f.name}" on type "${type.name}".`, { locations: [f.loc] }));
      continue;
    }
    for (const a of Object.keys(f.args)) {
      if (!def.args?.[a]) errors.push(new GraphQLError(`Unknown argument "${a}" on field "${type.name}.${f.name}".`, { locations: [f.loc] }));
    }
    for (const [a, ad] of Object.entries(def.args ?? {})) {
      if (ad.type.endsWith("!") && ad.defaultValue === undefined && !f.args[a]) {
        errors.push(new GraphQLError(`Field "${f.name}" argument "${a}" of type "${ad.type}" is required, but it was not provided.`, { locations: [f.loc] }));
      }
    }
    const named = namedType(parseType(def.type));
    if (SCALARS.has(named)) {
      if (f.selections) errors.push(new GraphQLError(`Field "${f.name}" must not have a selection since type "${def.type}" has no subfields.`, { locations: [f.loc] }));
      max = Math.max(max, depth + 1);
    } else if (!f.selections) {
      errors.push(new GraphQLError(`Field "${f.name}" of type "${def.type}" must have a selection of subfields. Did you mean "${f.name} { ... }"?`, { locations: [f.loc] }));
    } else {
      max = Math.max(max, validateSelections(types[named]!, f.selections, doc, errors, depth + 1));
    }
  }
  return max;
}

/* ------------------------------------------------------------ execution */

/** Thrown when a non-null field resolved to null: the parent must become null too. */
class NullBubble {}

type ExecState = { doc: DocumentNode; vars: Record<string, unknown>; ctx: Ctx; errors: GraphQLError[] };

async function executeFields(type: ObjectType, parent: unknown, sels: SelectionNode[], path: (string | number)[], st: ExecState, serial: boolean) {
  const fields = new Map<string, FieldNode>();
  for (const f of collect(type.name, sels, st.doc, [])) {
    const key = f.alias ?? f.name;
    if (!fields.has(key)) fields.set(key, f);
  }
  const out: Record<string, unknown> = {};
  if (serial) {
    for (const [key, f] of fields) out[key] = await executeField(type, parent, f, [...path, key], st);
  } else {
    const entries = [...fields];
    const values = await Promise.all(entries.map(([key, f]) => executeField(type, parent, f, [...path, key], st)));
    entries.forEach(([key], i) => (out[key] = values[i]));
  }
  return out;
}

async function executeField(type: ObjectType, parent: unknown, node: FieldNode, path: (string | number)[], st: ExecState): Promise<unknown> {
  if (node.name === "__typename") return type.name;
  const def = type.fields[node.name]!;
  const t = parseType(def.type);
  try {
    let raw: unknown;
    if (def.resolve) {
      const args = coerceArgs(type.name, node.name, def, node, st.vars);
      st.ctx.stats.resolverCalls++;
      raw = await def.resolve(parent, args, st.ctx);
    } else {
      raw = (parent as Record<string, unknown>)[node.name];
    }
    return await complete(t, raw, node, path, st, `${type.name}.${node.name}`);
  } catch (e) {
    if (!(e instanceof NullBubble)) {
      const err = e instanceof GraphQLError ? e : new GraphQLError((e as Error).message, { code: "INTERNAL_SERVER_ERROR" });
      st.errors.push(new GraphQLError(err.message, { ...err.opts, path, locations: [node.loc], code: err.opts.code ?? "INTERNAL_SERVER_ERROR" }));
    }
    if (t.kind === "NonNull") throw new NullBubble();
    return null;
  }
}

async function complete(t: TypeNode, v: unknown, node: FieldNode, path: (string | number)[], st: ExecState, fieldName: string): Promise<unknown> {
  if (t.kind === "NonNull") {
    const r = await complete(t.of, v, node, path, st, fieldName);
    if (r === null) throw new GraphQLError(`Cannot return null for non-nullable field ${fieldName}.`, { code: "INTERNAL_SERVER_ERROR" });
    return r;
  }
  if (v === null || v === undefined) return null;
  if (t.kind === "List") {
    if (!Array.isArray(v)) throw new GraphQLError(`Expected a list for ${fieldName}.`);
    return Promise.all(v.map((item, i) => complete(t.of, item, node, [...path, i], st, fieldName)));
  }
  if (SCALARS.has(t.name)) return t.name === "ID" ? String(v) : v;
  return executeFields(types[t.name]!, v, node.selections ?? [], path, st, false);
}

/* --------------------------------------------------------------- entry */

export type ExecResult = {
  response: { data?: Record<string, unknown> | null; errors?: ReturnType<GraphQLError["toJSON"]>[] };
  operation: "query" | "mutation" | null;
  depth: number;
};

export async function execute(source: string, variablesJson: string, ctx: Ctx, operationName?: string): Promise<ExecResult> {
  let doc: DocumentNode;
  try {
    doc = parse(source);
  } catch (e) {
    return { response: { errors: [(e as GraphQLError).toJSON()] }, operation: null, depth: 0 };
  }
  const errOut = (errors: GraphQLError[], operation: "query" | "mutation" | null = null, depth = 0): ExecResult => ({
    response: { errors: errors.map((e) => e.toJSON()) },
    operation,
    depth,
  });

  const ops = operationName ? doc.operations.filter((o) => o.name === operationName) : doc.operations;
  if (ops.length !== 1) {
    return errOut([new GraphQLError(ops.length ? "Must provide operation name if query contains multiple operations." : `Unknown operation named "${operationName}".`)]);
  }
  const op = ops[0]!;

  let rawVars: Record<string, unknown> = {};
  try {
    const parsed: unknown = variablesJson.trim() ? JSON.parse(variablesJson) : {};
    if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error();
    rawVars = parsed as Record<string, unknown>;
  } catch {
    return errOut([new GraphQLError("Variables must be a JSON object.", { code: "BAD_USER_INPUT" })], op.operation);
  }
  const vars: Record<string, unknown> = {};
  try {
    for (const d of op.variables) {
      let v = rawVars[d.name];
      if (v === undefined && d.defaultValue) v = valueFromAst(d.defaultValue, {});
      if (v === undefined) {
        if (d.type.endsWith("!")) throw new GraphQLError(`Variable "$${d.name}" of required type "${d.type}" was not provided.`, { code: "BAD_USER_INPUT" });
        continue;
      }
      vars[d.name] = coerceInput(parseType(d.type), v, `Variable "$${d.name}"`);
    }
  } catch (e) {
    return errOut([e as GraphQLError], op.operation);
  }

  const root = op.operation === "mutation" ? types.Mutation! : types.Query!;
  const errors: GraphQLError[] = [];
  const depth = validateSelections(root, op.selections, doc, errors, 0);
  const used = new Set<string>();
  const findVars = (sels: SelectionNode[]) => {
    for (const s of sels) {
      if (s.kind === "Field") {
        const walk = (v: ValueNode) => {
          if (v.kind === "Variable") used.add(v.name);
          else if (v.kind === "List") v.values.forEach(walk);
          else if (v.kind === "Object") Object.values(v.fields).forEach(walk);
        };
        Object.values(s.args).forEach(walk);
        if (s.selections) findVars(s.selections);
      } else if (s.kind === "InlineFragment") findVars(s.selections);
      else if (doc.fragments[s.name]) findVars(doc.fragments[s.name]!.selections);
    }
  };
  findVars(op.selections);
  for (const u of used) {
    if (!op.variables.some((d) => d.name === u)) errors.push(new GraphQLError(`Variable "$${u}" is not defined${op.name ? ` by operation "${op.name}"` : ""}.`));
  }
  if (depth > MAX_DEPTH) {
    errors.push(new GraphQLError(`Query depth ${depth} exceeds the maximum allowed depth of ${MAX_DEPTH}.`, { code: "DEPTH_LIMIT_EXCEEDED" }));
  }
  if (errors.length) return errOut(errors, op.operation, depth);

  const st: ExecState = { doc, vars, ctx, errors: [] };
  let data: Record<string, unknown> | null;
  try {
    data = await executeFields(root, null, op.selections, [], st, op.operation === "mutation");
  } catch (e) {
    if (!(e instanceof NullBubble)) throw e;
    data = null;
  }
  return { response: st.errors.length ? { errors: st.errors.map((e) => e.toJSON()), data } : { data }, operation: op.operation, depth };
}
app/lab/nestjs-graphql-api/parser.ts311 lines
/**
 * A small GraphQL parser: enough of the October 2021 spec for real-looking operations.
 * Supports queries and mutations, operation names, variables with defaults, aliases, arguments
 * (all literal kinds), nested selections, named fragments and inline fragments. Comments and commas are ignored.
 *
 * In production this job belongs to graphql-js inside Apollo Server; writing it out keeps the demo dependency-free
 * and makes every step visible under "View source".
 */

export type Loc = { line: number; column: number };

export class GraphQLError extends Error {
  constructor(
    message: string,
    readonly opts: { locations?: Loc[]; path?: (string | number)[]; code?: string } = {},
  ) {
    super(message);
  }
  toJSON() {
    return {
      message: this.message,
      ...(this.opts.locations ? { locations: this.opts.locations } : {}),
      ...(this.opts.path ? { path: this.opts.path } : {}),
      extensions: { code: this.opts.code ?? (this.message.startsWith("Syntax Error") ? "GRAPHQL_PARSE_FAILED" : "GRAPHQL_VALIDATION_FAILED") },
    };
  }
}

export type ValueNode =
  | { kind: "Int"; value: number }
  | { kind: "Float"; value: number }
  | { kind: "String"; value: string }
  | { kind: "Boolean"; value: boolean }
  | { kind: "Null" }
  | { kind: "Enum"; value: string }
  | { kind: "List"; values: ValueNode[] }
  | { kind: "Object"; fields: Record<string, ValueNode> }
  | { kind: "Variable"; name: string };

export type FieldNode = {
  kind: "Field";
  alias: string | null;
  name: string;
  args: Record<string, ValueNode>;
  selections: SelectionNode[] | null;
  loc: Loc;
};
export type SelectionNode =
  | FieldNode
  | { kind: "FragmentSpread"; name: string; loc: Loc }
  | { kind: "InlineFragment"; typeCondition: string | null; selections: SelectionNode[]; loc: Loc };

export type VariableDef = { name: string; type: string; defaultValue: ValueNode | null };
export type OperationNode = {
  kind: "Operation";
  operation: "query" | "mutation";
  name: string | null;
  variables: VariableDef[];
  selections: SelectionNode[];
  loc: Loc;
};
export type FragmentNode = { name: string; typeCondition: string; selections: SelectionNode[] };
export type DocumentNode = { operations: OperationNode[]; fragments: Record<string, FragmentNode> };

/* ------------------------------------------------------------------ lexer */

type Tok = { kind: "punct" | "name" | "int" | "float" | "string" | "eof"; value: string; loc: Loc };

function lex(src: string): Tok[] {
  const out: Tok[] = [];
  let i = 0;
  let line = 1;
  let lineStart = 0;
  const loc = (at: number): Loc => ({ line, column: at - lineStart + 1 });

  while (i < src.length) {
    const c = src[i]!;
    if (c === "\n") {
      line++;
      lineStart = ++i;
      continue;
    }
    if (c === " " || c === "\t" || c === "\r" || c === "," || c === "") {
      i++;
      continue;
    }
    if (c === "#") {
      while (i < src.length && src[i] !== "\n") i++;
      continue;
    }
    if (src.startsWith("...", i)) {
      out.push({ kind: "punct", value: "...", loc: loc(i) });
      i += 3;
      continue;
    }
    if ("!$():=@[]{}|&".includes(c)) {
      out.push({ kind: "punct", value: c, loc: loc(i) });
      i++;
      continue;
    }
    if (/[_A-Za-z]/.test(c)) {
      const start = i;
      while (i < src.length && /[_0-9A-Za-z]/.test(src[i]!)) i++;
      out.push({ kind: "name", value: src.slice(start, i), loc: loc(start) });
      continue;
    }
    if (c === "-" || /[0-9]/.test(c)) {
      const m = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/.exec(src.slice(i));
      if (!m) throw new GraphQLError(`Syntax Error: Invalid number.`, { locations: [loc(i)] });
      out.push({ kind: m[2] || m[3] ? "float" : "int", value: m[0], loc: loc(i) });
      i += m[0].length;
      continue;
    }
    if (c === '"') {
      const start = i++;
      let s = "";
      while (i < src.length && src[i] !== '"') {
        if (src[i] === "\n") throw new GraphQLError("Syntax Error: Unterminated string.", { locations: [loc(start)] });
        if (src[i] === "\\") {
          const e = src[i + 1];
          const map: Record<string, string> = { n: "\n", t: "\t", '"': '"', "\\": "\\", "/": "/", r: "\r", b: "\b", f: "\f" };
          if (e === "u") {
            s += String.fromCharCode(parseInt(src.slice(i + 2, i + 6), 16));
            i += 6;
            continue;
          }
          s += map[e ?? ""] ?? "";
          i += 2;
          continue;
        }
        s += src[i++];
      }
      if (src[i] !== '"') throw new GraphQLError("Syntax Error: Unterminated string.", { locations: [loc(start)] });
      i++;
      out.push({ kind: "string", value: s, loc: loc(start) });
      continue;
    }
    throw new GraphQLError(`Syntax Error: Unexpected character "${c}".`, { locations: [loc(i)] });
  }
  out.push({ kind: "eof", value: "<EOF>", loc: loc(i) });
  return out;
}

/* ----------------------------------------------------------------- parser */

export function parse(src: string): DocumentNode {
  const toks = lex(src);
  let p = 0;
  const peek = () => toks[p]!;
  const next = () => toks[p++]!;
  const fail = (t: Tok, msg?: string): never => {
    throw new GraphQLError(`Syntax Error: ${msg ?? `Unexpected ${t.kind === "eof" ? "<EOF>" : `"${t.value}"`}.`}`, { locations: [t.loc] });
  };
  const isPunct = (v: string) => peek().kind === "punct" && peek().value === v;
  const expect = (v: string) => (isPunct(v) ? next() : fail(peek(), `Expected "${v}", found ${peek().kind === "eof" ? "<EOF>" : `"${peek().value}"`}.`));
  const name = () => (peek().kind === "name" ? next().value : fail(peek(), `Expected Name, found ${peek().kind === "eof" ? "<EOF>" : `"${peek().value}"`}.`));

  function value(isConst: boolean): ValueNode {
    const t = peek();
    if (t.kind === "punct" && t.value === "$") {
      if (isConst) fail(t, "Unexpected variable in a constant value.");
      next();
      return { kind: "Variable", name: name() };
    }
    if (t.kind === "int") return next(), { kind: "Int", value: Number(t.value) };
    if (t.kind === "float") return next(), { kind: "Float", value: Number(t.value) };
    if (t.kind === "string") return next(), { kind: "String", value: t.value };
    if (t.kind === "name") {
      next();
      if (t.value === "true" || t.value === "false") return { kind: "Boolean", value: t.value === "true" };
      if (t.value === "null") return { kind: "Null" };
      return { kind: "Enum", value: t.value };
    }
    if (isPunct("[")) {
      next();
      const values: ValueNode[] = [];
      while (!isPunct("]")) {
        if (peek().kind === "eof") fail(peek());
        values.push(value(isConst));
      }
      next();
      return { kind: "List", values };
    }
    if (isPunct("{")) {
      next();
      const fields: Record<string, ValueNode> = {};
      while (!isPunct("}")) {
        const k = name();
        expect(":");
        fields[k] = value(isConst);
      }
      next();
      return { kind: "Object", fields };
    }
    return fail(t);
  }

  function typeRef(): string {
    let t: string;
    if (isPunct("[")) {
      next();
      t = `[${typeRef()}]`;
      expect("]");
    } else t = name();
    if (isPunct("!")) {
      next();
      t += "!";
    }
    return t;
  }

  function args(): Record<string, ValueNode> {
    const out: Record<string, ValueNode> = {};
    if (!isPunct("(")) return out;
    next();
    while (!isPunct(")")) {
      const k = name();
      expect(":");
      out[k] = value(false);
    }
    next();
    return out;
  }

  function directivesNotSupported() {
    if (isPunct("@")) fail(peek(), "Directives are not supported in this demo.");
  }

  function selectionSet(): SelectionNode[] {
    expect("{");
    const out: SelectionNode[] = [];
    while (!isPunct("}")) {
      const t = peek();
      if (t.kind === "eof") fail(t, `Expected Name, found <EOF>.`);
      if (isPunct("...")) {
        next();
        if (peek().kind === "name" && peek().value !== "on") {
          out.push({ kind: "FragmentSpread", name: next().value, loc: t.loc });
        } else {
          let typeCondition: string | null = null;
          if (peek().value === "on") {
            next();
            typeCondition = name();
          }
          out.push({ kind: "InlineFragment", typeCondition, selections: selectionSet(), loc: t.loc });
        }
        continue;
      }
      let alias: string | null = null;
      let n = name();
      if (isPunct(":")) {
        next();
        alias = n;
        n = name();
      }
      const a = args();
      directivesNotSupported();
      out.push({ kind: "Field", alias, name: n, args: a, selections: isPunct("{") ? selectionSet() : null, loc: t.loc });
    }
    next();
    if (!out.length) fail(toks[p - 1]!, "Expected Name, found \"}\".");
    return out;
  }

  const doc: DocumentNode = { operations: [], fragments: {} };
  while (peek().kind !== "eof") {
    const t = peek();
    if (isPunct("{")) {
      doc.operations.push({ kind: "Operation", operation: "query", name: null, variables: [], selections: selectionSet(), loc: t.loc });
      continue;
    }
    if (t.kind === "name" && (t.value === "query" || t.value === "mutation")) {
      next();
      const opName = peek().kind === "name" ? next().value : null;
      const variables: VariableDef[] = [];
      if (isPunct("(")) {
        next();
        while (!isPunct(")")) {
          expect("$");
          const vn = name();
          expect(":");
          const type = typeRef();
          let defaultValue: ValueNode | null = null;
          if (isPunct("=")) {
            next();
            defaultValue = value(true);
          }
          variables.push({ name: vn, type, defaultValue });
        }
        next();
      }
      directivesNotSupported();
      doc.operations.push({ kind: "Operation", operation: t.value as "query" | "mutation", name: opName, variables, selections: selectionSet(), loc: t.loc });
      continue;
    }
    if (t.kind === "name" && t.value === "fragment") {
      next();
      const fn = name();
      if (peek().value !== "on") fail(peek(), `Expected "on", found "${peek().value}".`);
      next();
      const typeCondition = name();
      doc.fragments[fn] = { name: fn, typeCondition, selections: selectionSet() };
      continue;
    }
    if (t.kind === "name" && t.value === "subscription") fail(t, "Subscriptions are not part of this schema.");
    fail(t);
  }
  if (!doc.operations.length) throw new GraphQLError("Syntax Error: The document has no operation.", { locations: [{ line: 1, column: 1 }] });
  return doc;
}
app/lab/nestjs-graphql-api/data.ts147 lines
/**
 * Seed data and the repository layer for the recipe catalogue.
 * Every repository call appends the SQL it stands for to ctx.sql, which is how the demo counts queries.
 * Table layout matches the production design: authors, recipes, ingredients, recipe_ingredients, reviews.
 */

export type Author = { id: number; name: string; bio: string };
export type Recipe = { id: number; title: string; minutes: number; tags: string[]; authorId: number; createdAt: string };
export type Ingredient = { id: number; name: string };
export type Review = { id: number; recipeId: number; authorId: number; rating: number; body: string | null };
export type Db = {
  authors: Author[];
  recipes: Recipe[];
  ingredients: Ingredient[];
  recipeIngredients: { recipeId: number; ingredientId: number }[];
  reviews: Review[];
};

export function seedDb(): Db {
  const authors: Author[] = [
    { id: 1, name: "Lena Park", bio: "Weeknight cooking, mostly one pan." },
    { id: 2, name: "Omar Haddad", bio: "Bread, grills and slow Sunday food." },
    { id: 3, name: "Priya Nair", bio: "Spice-forward vegetarian dishes." },
    { id: 4, name: "Tom Becker", bio: "Baking by weight, never by cups." },
    { id: 5, name: "Sara Malik", bio: "Lunchbox and meal-prep recipes." },
    { id: 6, name: "Jonas Weber", bio: "Soups and stews for cold evenings." },
  ];
  const ingredientNames = [
    "chickpeas", "garlic", "onion", "tomato", "rice", "lentils", "spinach", "lemon",
    "yogurt", "flour", "butter", "egg", "potato", "chicken", "cumin", "coriander",
  ];
  const ingredients = ingredientNames.map((name, i) => ({ id: i + 1, name }));
  const titles: [string, number, string[]][] = [
    ["One-pan chickpea stew", 30, ["vegan", "weeknight"]],
    ["Garlic flatbread", 45, ["bread"]],
    ["Red lentil soup", 35, ["vegan", "soup"]],
    ["Lemon yogurt cake", 70, ["baking"]],
    ["Spinach rice", 25, ["weeknight", "vegetarian"]],
    ["Roast potato salad", 50, ["vegetarian"]],
    ["Cumin chicken skewers", 40, ["grill"]],
    ["Tomato and egg scramble", 15, ["breakfast", "weeknight"]],
    ["Butter rolls", 120, ["bread", "baking"]],
    ["Coriander chutney", 10, ["vegan", "sauce"]],
    ["Chicken and rice soup", 60, ["soup"]],
    ["Lentil meal-prep bowls", 40, ["meal-prep", "vegan"]],
    ["Crispy potato wedges", 45, ["vegetarian"]],
    ["Lemon garlic chicken", 35, ["weeknight"]],
    ["Spiced onion fritters", 30, ["vegetarian", "snack"]],
    ["Tomato rice", 30, ["vegan", "weeknight"]],
    ["Egg fried rice", 20, ["weeknight"]],
    ["Winter vegetable stew", 90, ["soup", "vegan"]],
  ];
  const recipes: Recipe[] = titles.map(([title, minutes, tags], i) => ({
    id: i + 1,
    title,
    minutes,
    tags,
    authorId: (i % 6) + 1,
    createdAt: new Date(Date.UTC(2026, 8, 1 + i)).toISOString(),
  }));
  const recipeIngredients: Db["recipeIngredients"] = [];
  for (const r of recipes) for (let k = 0; k < 3; k++) recipeIngredients.push({ recipeId: r.id, ingredientId: ((r.id * 5 + k * 7) % 16) + 1 });
  const BODIES = ["Made it twice this week.", "Needed more salt for us.", "Kids loved it.", null, "Great with leftovers.", "Halved the chilli."];
  const reviews: Review[] = [];
  for (const r of recipes) {
    const n = 1 + (r.id % 3);
    for (let k = 0; k < n; k++) {
      const authorId = ((r.authorId + k) % 6) + 1;
      reviews.push({ id: reviews.length + 1, recipeId: r.id, authorId, rating: 3 + ((r.id + k) % 3), body: BODIES[(r.id + k) % BODIES.length] ?? null });
    }
  }
  return { authors, recipes, ingredients, recipeIngredients: dedupe(recipeIngredients), reviews };
}

function dedupe(rows: Db["recipeIngredients"]) {
  const seen = new Set<string>();
  return rows.filter((r) => {
    const k = `${r.recipeId}:${r.ingredientId}`;
    if (seen.has(k)) return false;
    seen.add(k);
    return true;
  });
}

/** Repository: one method per SQL statement. Batched variants take many keys and run one query. */
export function repo(db: Db, sql: string[]) {
  const ids = (xs: readonly number[]) => `{${xs.join(",")}}`;
  return {
    /** Writes: the mutation resolvers change rows directly and record the statement here. */
    exec(statement: string) {
      sql.push(statement);
    },
    authorById(id: number) {
      sql.push(`SELECT * FROM authors WHERE id = ${id}`);
      return db.authors.find((a) => a.id === id) ?? null;
    },
    authorsByIds(keys: readonly number[]) {
      sql.push(`SELECT * FROM authors WHERE id = ANY('${ids(keys)}')`);
      return keys.map((k) => db.authors.find((a) => a.id === k) ?? null);
    },
    allAuthors() {
      sql.push(`SELECT * FROM authors ORDER BY id`);
      return db.authors;
    },
    recipeById(id: number) {
      sql.push(`SELECT * FROM recipes WHERE id = ${id}`);
      return db.recipes.find((r) => r.id === id) ?? null;
    },
    recipesPage(f: { tag?: string; maxMinutes?: number; search?: string }, afterId: number, limit: number) {
      const where = [`id > ${afterId}`];
      if (f.tag) where.push(`'${f.tag}' = ANY(tags)`);
      if (f.maxMinutes !== undefined) where.push(`minutes <= ${f.maxMinutes}`);
      if (f.search) where.push(`title ILIKE '%${f.search}%'`);
      sql.push(`SELECT *, count(*) OVER () FROM recipes WHERE ${where.join(" AND ")} ORDER BY id LIMIT ${limit + 1}`);
      const all = db.recipes
        .filter((r) => (!f.tag || r.tags.includes(f.tag)) && (f.maxMinutes === undefined || r.minutes <= f.maxMinutes) && (!f.search || r.title.toLowerCase().includes(f.search.toLowerCase())))
        .sort((a, b) => a.id - b.id);
      return { rows: all.filter((r) => r.id > afterId).slice(0, limit + 1), total: all.length };
    },
    recipesByAuthor(authorId: number) {
      sql.push(`SELECT * FROM recipes WHERE author_id = ${authorId} ORDER BY id`);
      return db.recipes.filter((r) => r.authorId === authorId);
    },
    recipesByAuthors(keys: readonly number[]) {
      sql.push(`SELECT * FROM recipes WHERE author_id = ANY('${ids(keys)}') ORDER BY id`);
      return keys.map((k) => db.recipes.filter((r) => r.authorId === k));
    },
    ingredientsByRecipe(recipeId: number) {
      sql.push(`SELECT i.* FROM ingredients i JOIN recipe_ingredients ri ON ri.ingredient_id = i.id WHERE ri.recipe_id = ${recipeId}`);
      return db.recipeIngredients.filter((x) => x.recipeId === recipeId).map((x) => db.ingredients.find((i) => i.id === x.ingredientId)!);
    },
    ingredientsByRecipes(keys: readonly number[]) {
      sql.push(`SELECT ri.recipe_id, i.* FROM ingredients i JOIN recipe_ingredients ri ON ri.ingredient_id = i.id WHERE ri.recipe_id = ANY('${ids(keys)}')`);
      return keys.map((k) => db.recipeIngredients.filter((x) => x.recipeId === k).map((x) => db.ingredients.find((i) => i.id === x.ingredientId)!));
    },
    reviewsByRecipe(recipeId: number) {
      sql.push(`SELECT * FROM reviews WHERE recipe_id = ${recipeId} ORDER BY id`);
      return db.reviews.filter((r) => r.recipeId === recipeId);
    },
    reviewsByRecipes(keys: readonly number[]) {
      sql.push(`SELECT * FROM reviews WHERE recipe_id = ANY('${ids(keys)}') ORDER BY id`);
      return keys.map((k) => db.reviews.filter((r) => r.recipeId === k));
    },
  };
}
export type Repo = ReturnType<typeof repo>;
app/lab/nestjs-graphql-api/Demo.tsx417 lines
"use client";

import { useMemo, useState } from "react";
import { JsonCode } from "@/components/lab/api/ApiConsole";
import { DbTable } from "@/components/lab/api/DbTable";
import { RequestLog, appendLog, type LogLine } from "@/components/lab/api/RequestLog";
import { usePersisted } from "@/components/lab/api/usePersisted";
import { repo, seedDb, type Db } from "./data";
import { MAX_DEPTH, execute, type ExecResult } from "./execute";
import { createLoaders, printSchema, type Stats } from "./schema";

const KEY = "lab:nestjs-graphql-api:v1";

const EXAMPLES: { label: string; query: string; variables?: string; viewer?: number | null }[] = [
  {
    label: "Recipes with authors and reviews (N+1 shape)",
    query: `query RecipeList($first: Int = 8) {
  recipes(first: $first) {
    totalCount
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        title
        minutes
        author { name }
        averageRating
        reviews { rating author { name } }
      }
    }
  }
}`,
    variables: `{ "first": 8 }`,
  },
  {
    label: "Next page with a cursor",
    query: `query NextPage($after: String) {
  recipes(first: 4, after: $after, filter: { maxMinutes: 45 }) {
    edges { cursor node { id title minutes } }
    pageInfo { hasNextPage endCursor }
  }
}`,
    variables: `{ "after": "UmVjaXBlOjU=" }`,
  },
  {
    label: "One recipe with a fragment",
    query: `query OneRecipe($id: ID!) {
  recipe(id: $id) {
    ...RecipeCard
    ingredients { name }
  }
}

fragment RecipeCard on Recipe {
  title
  tags
  author { name bio }
}`,
    variables: `{ "id": "3" }`,
  },
  {
    label: "Authors and their recipes",
    query: `{
  authors {
    name
    recipes { title reviews { rating } }
  }
}`,
  },
  {
    label: "Create a recipe (needs login)",
    query: `mutation Create($input: CreateRecipeInput!) {
  createRecipe(input: $input) {
    id
    title
    author { name }
    ingredients { name }
  }
}`,
    variables: `{
  "input": {
    "title": "Garlic lentil toast",
    "minutes": 20,
    "tags": ["weeknight"],
    "ingredientIds": ["2", "6", "8"]
  }
}`,
  },
  {
    label: "Update someone else's recipe (FORBIDDEN)",
    query: `mutation {
  updateRecipe(id: "2", input: { title: "Not mine" }) { id title }
}`,
    viewer: 1,
  },
  {
    label: "Review a recipe",
    query: `mutation Review($recipeId: ID!, $rating: Int!) {
  addReview(recipeId: $recipeId, rating: $rating, body: "Easy and quick.") {
    id
    rating
    author { name }
  }
}`,
    variables: `{ "recipeId": "2", "rating": 5 }`,
  },
  {
    label: "Too deep (depth limit)",
    query: `{
  recipes(first: 2) {
    edges { node { author { recipes { author { recipes { title } } } } } }
  }
}`,
  },
  {
    label: "Typo in a field",
    query: `{
  recipe(id: 1) { titel minutes }
}`,
  },
];

type State = { db: Db; viewerId: number | null; loaders: boolean; query: string; variables: string };
const seed = (): State => ({ db: seedDb(), viewerId: 1, loaders: false, query: EXAMPLES[0]!.query, variables: EXAMPLES[0]!.variables ?? "{}" });

type Run = { result: ExecResult; sql: string[]; stats: Stats; ms: number; loaders: boolean };

async function runOnce(db: Db, viewerId: number | null, loaders: boolean, query: string, variables: string): Promise<Run & { db: Db }> {
  const draft = structuredClone(db);
  const sql: string[] = [];
  const r = repo(draft, sql);
  const stats: Stats = { resolverCalls: 0, batches: [] };
  const viewer = draft.authors.find((a) => a.id === viewerId) ?? null;
  const t0 = performance.now();
  const result = await execute(query, variables, { db: draft, repo: r, viewer, loaders: loaders ? createLoaders(r, stats) : null, stats });
  return { result, sql, stats, ms: Math.round((performance.now() - t0) * 10) / 10, loaders, db: draft };
}

function Stat({ label, value, tone }: { label: string; value: string | number; tone?: string }) {
  return (
    <div className="rounded-md border border-border bg-bg-elev px-3 py-2">
      <div className={`font-mono text-lg font-semibold ${tone ?? ""}`}>{value}</div>
      <div className="text-[11px] text-fg-muted">{label}</div>
    </div>
  );
}

export default function Demo() {
  const [state, setState, resetState] = usePersisted<State>(KEY, seed);
  const [run, setRun] = useState<Run | null>(null);
  const [compare, setCompare] = useState<{ plain: Run; batched: Run } | null>(null);
  const [busy, setBusy] = useState(false);
  const [log, setLog] = useState<LogLine[]>([]);
  const [tab, setTab] = useState<"schema" | "recipes" | "reviews" | "authors">("schema");
  const [example, setExample] = useState("0");
  const sdl = useMemo(() => printSchema(), []);

  const viewer = state.db.authors.find((a) => a.id === state.viewerId) ?? null;

  async function onRun() {
    if (busy) return;
    setBusy(true);
    const out = await runOnce(state.db, state.viewerId, state.loaders, state.query, state.variables);
    setRun(out);
    setCompare(null);
    const isMutation = out.result.operation === "mutation";
    if (isMutation && out.result.response.data) setState((s) => ({ ...s, db: out.db }));
    const errs = out.result.response.errors ?? [];
    setLog((l) =>
      appendLog(l, [
        { text: `→ POST /graphql  ${out.result.operation ?? "invalid"} · viewer=${viewer?.name ?? "anonymous"} · dataloader=${out.loaders ? "on" : "off"}`, tone: "info" },
        ...out.sql.map((q) => ({ text: `  ${q}`, tone: "sql" as const })),
        errs.length
          ? { text: `← 200 with ${errs.length} error(s): ${errs[0]!.extensions.code} ${errs[0]!.message}`, tone: "warn" as const }
          : { text: `← 200 OK · ${out.sql.length} SQL · ${out.stats.resolverCalls} resolver calls`, tone: "ok" as const },
      ]),
    );
    setBusy(false);
  }

  async function onCompare() {
    if (busy) return;
    setBusy(true);
    const [plain, batched] = await Promise.all([
      runOnce(state.db, state.viewerId, false, state.query, state.variables),
      runOnce(state.db, state.viewerId, true, state.query, state.variables),
    ]);
    if (plain.result.operation === "mutation") {
      setCompare(null);
      setLog((l) => appendLog(l, [{ text: "# compare runs queries only (it would apply a mutation twice)", tone: "dim" }]));
    } else {
      setCompare({ plain, batched });
      setRun(batched);
      setLog((l) => appendLog(l, [{ text: `# compare: ${plain.sql.length} SQL without DataLoader, ${batched.sql.length} with`, tone: "ok" }]));
    }
    setBusy(false);
  }

  function pickExample(i: string) {
    setExample(i);
    const ex = EXAMPLES[Number(i)];
    if (!ex) return;
    setState((s) => ({ ...s, query: ex.query, variables: ex.variables ?? "{}", viewerId: ex.viewer !== undefined ? ex.viewer : s.viewerId }));
  }

  function reset() {
    resetState();
    setRun(null);
    setCompare(null);
    setLog([]);
    setExample("0");
  }

  const onKey = (e: React.KeyboardEvent) => {
    if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      void onRun();
    }
  };

  const input = "w-full rounded-md border border-border bg-bg-elev px-3 py-2 font-mono text-[13px] leading-relaxed outline-none focus:border-accent";
  const btn = "rounded-md border border-border px-3 py-1.5 font-mono text-xs hover:border-accent hover:text-accent";
  const errors = run?.result.response.errors ?? [];

  return (
    <div>
      <div className="flex flex-wrap items-end gap-3 border-b border-border bg-bg-elev p-4">
        <label className="min-w-0 flex-1 basis-56">
          <span className="font-mono text-xs text-fg-muted">example operation</span>
          <select value={example} onChange={(e) => pickExample(e.target.value)} className="mt-1 w-full rounded-md border border-border bg-bg px-2 py-1.5 text-sm">
            {EXAMPLES.map((ex, i) => (
              <option key={ex.label} value={i}>
                {ex.label}
              </option>
            ))}
          </select>
        </label>
        <label>
          <span className="font-mono text-xs text-fg-muted">viewer (JWT in context)</span>
          <select
            value={state.viewerId ?? ""}
            onChange={(e) => setState((s) => ({ ...s, viewerId: e.target.value ? Number(e.target.value) : null }))}
            className="mt-1 block rounded-md border border-border bg-bg px-2 py-1.5 text-sm"
          >
            <option value="">anonymous</option>
            {state.db.authors.map((a) => (
              <option key={a.id} value={a.id}>
                {a.name} (author {a.id})
              </option>
            ))}
          </select>
        </label>
        <label className="flex items-center gap-2 pb-1.5 font-mono text-xs">
          <input type="checkbox" checked={state.loaders} onChange={(e) => setState((s) => ({ ...s, loaders: e.target.checked }))} className="size-4 accent-[var(--accent)]" />
          DataLoader
        </label>
        <div className="flex gap-2">
          <button type="button" onClick={() => void onRun()} disabled={busy} className="rounded-md bg-accent px-4 py-1.5 font-mono text-sm font-semibold text-accent-fg hover:opacity-90 disabled:opacity-60">
            ▶ Run
          </button>
          <button type="button" onClick={() => void onCompare()} disabled={busy} className={btn}>
            Compare N+1 vs DataLoader
          </button>
        </div>
      </div>

      <div className="grid lg:grid-cols-2">
        <div className="space-y-3 p-4">
          <label className="block">
            <span className="flex justify-between font-mono text-xs text-fg-muted">
              <span>operation</span>
              <span className="hidden sm:inline">Ctrl/⌘ + Enter to run</span>
            </span>
            <textarea
              value={state.query}
              onChange={(e) => setState((s) => ({ ...s, query: e.target.value }))}
              onKeyDown={onKey}
              rows={16}
              spellCheck={false}
              className={`${input} mt-1 resize-y`}
            />
          </label>
          <label className="block">
            <span className="font-mono text-xs text-fg-muted">variables (JSON)</span>
            <textarea
              value={state.variables}
              onChange={(e) => setState((s) => ({ ...s, variables: e.target.value }))}
              onKeyDown={onKey}
              rows={4}
              spellCheck={false}
              className={`${input} mt-1 resize-y`}
            />
          </label>
        </div>

        <div className="min-w-0 space-y-3 border-t border-border p-4 lg:border-t-0 lg:border-l">
          <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
            <Stat label="SQL queries" value={run ? run.sql.length : "–"} tone={run && run.sql.length > 10 ? "text-danger" : "text-accent"} />
            <Stat label="resolver calls" value={run ? run.stats.resolverCalls : "–"} />
            <Stat label="loader batches" value={run ? (run.loaders ? run.stats.batches.length : "off") : "–"} />
            <Stat label={`depth (max ${MAX_DEPTH})`} value={run ? run.result.depth : "–"} tone={run && run.result.depth > MAX_DEPTH ? "text-danger" : undefined} />
          </div>
          <section aria-label="Response" aria-live="polite" aria-busy={busy} className="rounded-md border border-border bg-[#0b0b0d] p-3">
            {!run && <p className="font-mono text-xs text-[#71717a]">Press Run to execute the operation against the in-browser server.</p>}
            {run && (
              <>
                <p className="mb-2 font-mono text-[11px] text-[#a1a1aa]">
                  200 OK · {errors.length ? `${errors.length} error(s)` : "no errors"} · {run.ms} ms in your browser
                </p>
                <JsonCode value={run.result.response} className="max-h-[26rem]" />
              </>
            )}
          </section>
        </div>
      </div>

      {compare && (
        <section aria-labelledby="cmp-h" className="border-t border-border p-4">
          <h3 id="cmp-h" className="font-mono text-sm font-semibold">
            same operation, two resolver strategies
          </h3>
          <div className="mt-3 grid gap-4 md:grid-cols-2">
            {([
              ["Without DataLoader", compare.plain, "text-danger"],
              ["With DataLoader", compare.batched, "text-accent"],
            ] as const).map(([label, r, tone]) => (
              <div key={label} className="min-w-0 rounded-md border border-border">
                <div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-border bg-bg-muted px-3 py-2">
                  <span className="text-sm font-medium">{label}</span>
                  <span className="font-mono text-xs">
                    <span className={`text-base font-semibold ${tone}`}>{r.sql.length}</span> SQL · {r.stats.resolverCalls} resolver calls
                    {r.loaders && ` · ${r.stats.batches.length} batches`}
                  </span>
                </div>
                <ol className="max-h-56 overflow-auto bg-[#0b0b0d] p-3 font-mono text-[11px] leading-relaxed">
                  {r.sql.map((q, i) => (
                    <li key={i} className="break-words text-[#93c5fd]">
                      <span className="text-[#52525b]">{String(i + 1).padStart(2, "0")} </span>
                      {q}
                    </li>
                  ))}
                </ol>
              </div>
            ))}
          </div>
          <p className="mt-2 text-xs text-fg-muted">
            Resolver calls are the same on both sides: DataLoader does not skip resolvers, it merges their database reads into one query per relation and level.
          </p>
        </section>
      )}

      <div className="grid gap-6 border-t border-border p-4 lg:grid-cols-[1fr_1fr]">
        <RequestLog lines={log} title="server log" onClear={() => setLog([])} className="h-72" />
        <section aria-label="Schema and data" className="min-w-0">
          <div role="tablist" aria-label="Schema and tables" className="flex flex-wrap gap-1">
            {(["schema", "recipes", "reviews", "authors"] as const).map((t) => (
              <button key={t} role="tab" type="button" aria-selected={tab === t} onClick={() => setTab(t)} className={`rounded px-2 py-0.5 font-mono text-xs ${tab === t ? "bg-accent text-accent-fg" : "text-fg-muted hover:text-fg"}`}>
                {t === "schema" ? "schema.gql" : t}
              </button>
            ))}
          </div>
          <div className="mt-2">
            {tab === "schema" && (
              <pre className="h-72 overflow-auto rounded-md border border-border bg-bg-muted p-3 font-mono text-[12px] leading-relaxed">{sdl}</pre>
            )}
            {tab === "recipes" && (
              <DbTable
                name="recipes"
                rows={state.db.recipes}
                rowKey={(r) => r.id}
                highlight={(r) => r.authorId === state.viewerId}
                caption={viewer ? `Tinted rows belong to ${viewer.name}, the only recipes this viewer may update.` : undefined}
                columns={[
                  { key: "id", label: "id" },
                  { key: "title", label: "title" },
                  { key: "minutes", label: "minutes" },
                  { key: "authorId", label: "author_id" },
                  { key: "tags", label: "tags", render: (r) => `{${r.tags.join(",")}}` },
                ]}
              />
            )}
            {tab === "reviews" && (
              <DbTable
                name="reviews"
                rows={state.db.reviews}
                rowKey={(r) => r.id}
                columns={[
                  { key: "id", label: "id" },
                  { key: "recipeId", label: "recipe_id" },
                  { key: "authorId", label: "author_id" },
                  { key: "rating", label: "rating" },
                  { key: "body", label: "body" },
                ]}
              />
            )}
            {tab === "authors" && (
              <DbTable
                name="authors"
                rows={state.db.authors}
                rowKey={(r) => r.id}
                columns={[
                  { key: "id", label: "id" },
                  { key: "name", label: "name" },
                  { key: "bio", label: "bio" },
                ]}
              />
            )}
          </div>
          <button type="button" onClick={reset} className={`${btn} mt-3`}>
            reset demo
          </button>
        </section>
      </div>
    </div>
  );
}