本文へ移動
cccskills
無料GitHub で公開

graphql-n-plus-one-remediation

Detects and fixes the GraphQL N+1 pattern: scans a repo, PR diff, or schema type for list-returning resolvers (grep-driven detection workflow), traces the resolver tree to locate the fan-out (one parent query returns N rows, then a child field resolver fires once per row), classifies every child field resolver as safe or N+1 risk, and applies one of three fixes: per-request DataLoader batching, eager projection in the parent resolver, or selection-set-aware prefetch. Use when reviewing a PR that adds or changes a list-returning resolver, when a connection-pool exhaustion or slow-query alert traces back to GraphQL traffic, or when a resolver trace shows a child field resolved once per parent row.

日本語の概要は準備中です。原文の説明を表示しています。

testland/qaスター 13(GitHub 13 + cccskills 0)
連携ツールRedis

実行スコア 未検証

インストール方法を見る

含まれるファイル(4)

  • SKILL.md13.0 KB
  • .tessl-plugin/plugin.json850 B
  • references/examples.md2.6 KB
  • references/fixes.md2.7 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

graphql-n-plus-one-remediation

What N+1 looks like in a resolver tree

One field returns a list of N rows. The executor then invokes the resolver for each inner field once per row, so if Query.topReviews returns ten reviews the executor resolves Review.product ten times, and a data-source call in that child field becomes ten calls (apollographql.com). So 1 outer query plus N inner queries equals N+1.

// Parent resolver fetches N posts
posts: () => db.posts.findMany({ limit: 50 })

// Inner field-resolver fires once per post (50 DB calls)
Post: {
  author: (post) => db.users.findOne({ where: { id: post.authorId } }),
}

GraphQL makes the pattern easy to hit because the resolver tree, not the caller, decides how many times a child field runs.

Observable symptoms (practitioner heuristics, not standardised thresholds):

  • Tail latency on a list field grows roughly in proportion to the number of rows the list returns.
  • Database connection pool exhaustion under modest GraphQL request rates.
  • A resolver-level trace shows the child field resolved once per parent row rather than once per request.

What this skill owns, and what it does not

Owned: locating the N+1 pattern inside a resolver tree and changing the data-loading strategy so the child field costs one batched call instead of N calls.

Not owned:

  • Query complexity or depth limiting. Those reject an expensive query before execution. They cap what a client may ask for. They do not make a legitimate list query cheap, and a schema with correct batching still needs them for denial-of-service protection.
  • Rate limiting. That caps how many requests a client sends. An N+1 resolver is still N+1 on the first allowed request.
  • Caching strategy. DataLoader's cache is a per-request memoization of .load() calls, not a shared store, and does not replace Redis or Memcache (github.com/graphql/dataloader). Deciding what to put in Redis is a different job from removing the fan-out.
  • SQL-level fan-out. A resolver that looks batched in JavaScript can still emit per-row SQL underneath. Confirm at the database layer with a query-log or slow-query analysis.

Step 0: Detection workflow (repo scan)

When the input is a repo, PR diff, or schema type rather than a known resolver, locate the candidates first. Input modes:

  • A specific resolver file or directory (resolvers/post.ts).
  • A PR diff (git diff main...HEAD --name-only -- '*resolvers*').
  • A schema type (User) - find all resolvers on that type.

Grep for list-returning resolvers and entry points:

grep -rn "resolve:.*=>" resolvers/
grep -rn "Query: {\|Mutation: {" resolvers/  # entry points
grep -rn "\[.*\]" schema/                     # list-typed fields in schema

Enumerate every resolver that returns a list and every field resolver on the types in those lists, then continue with Step 1. SQL-level fan-out beneath the ORM hands off to db-query-plan-analyzer.

Step 1: Map the parent and child pairs

For every field that returns a list, write down the type it returns and every field resolver defined on that type. That parent/child pair is the only place N+1 can appear: a field resolver on a non-list parent runs once and cannot fan out.

Record the pairs in a table before judging any of them:

Parent fieldReturnsChild field resolvers on the returned type
Query.posts[Post]Post.author, Post.comments
Post.comments[Comment]Comment.author

Nested list fields matter most: Query.posts returning 50 posts, each with Post.comments returning 10 comments, gives 500 invocations of Comment.author.

Step 2: Classify each child field resolver

Classify every child field resolver from the table above. Only the last three rows need a fix.

Resolver patternVerdict
Calls a batching loader (loader.load(id))Safe: batched per tick
Returns a value already on the parent object ((parent) => parent.author) and the parent query eagerly fetched itSafe: no data-source call
Reads from an in-memory structure already on the request contextSafe: no per-call data-source hit
Makes one database call per invocation (db.x.findOne({ ... }))N+1 risk
Makes one HTTP call per invocationN+1 risk, usually worse: each call pays network latency
Reads a related record through an ORM that lazy-loadsN+1 risk, and silent: the ORM hides the extra roundtrip

The second row has a trap. (parent) => parent.author is safe only when the parent query actually loaded author. In Prisma, related records are not returned unless the query asks for them via include or select (prisma.io). If the parent query has no matching include or select, that passthrough is the silent-lazy-load case, not the safe case.

Do not assume a framework batches for you. Verify batching in that framework's own documentation before marking a resolver safe.

Step 3: Pick one of three fixes

FixUse when
A: DataLoader batchingThe child field is cross-cutting: several parent types resolve it, or it is reached through more than one path in the same query
B: Projection in the parent resolverThe child field is needed on essentially every query of that parent
C: Prefetch with a selection-set-aware includeThe child field is sometimes requested, and projecting it unconditionally costs real work (a wide join, a large column)

Fix A: DataLoader batching

DataLoader coalesces every .load() issued within one tick of the event loop into a single batch call (github.com/graphql/dataloader), so N parents resolving the same field cost one call.

// In context setup, once per incoming request
const userLoader = new DataLoader<string, User | null>(async (ids) => {
  const users = await db.users.findMany({ where: { id: { in: [...ids] } } });
  return ids.map(id => users.find(u => u.id === id) ?? null);
});
context.loaders = { user: userLoader };

// Resolver becomes a load call
Post: {
  author: (post, _, ctx) => ctx.loaders.user.load(post.authorId),
}

The re-map line is mandatory: findMany returns rows in database order and drops missing ids, so the values array must be realigned to the keys array. Scope every loader to a single request; a module-level loader leaks one user's rows into another's response. Full batch-function contracts and the per-request scoping failure mode: references/fixes.md.

Fix B: Projection in the parent resolver

Ask the parent query for the related rows so the child resolver has nothing left to fetch.

posts: () => db.posts.findMany({
  limit: 50,
  include: { author: true },   // one query, relation eagerly loaded
})

// The child field-resolver is now a passthrough
Post: {
  author: (post) => post.author,
}

This is the eager-loading remedy EF Core recommends over lazy loading (learn.microsoft.com). Weigh one cost first: eagerly joining a one-to-many relation duplicates the parent columns across every child row (EF Core's "cartesian explosion"). Prefer B for to-one relations and to-many relations with small fan-out; detail in references/fixes.md.

Fix C: Prefetch with a selection-set-aware include

Read the incoming query's selection set in the parent resolver and project conditionally. parseResolveInfo from graphql-parse-resolve-info turns the fourth resolver argument into a tree whose fieldsByTypeName is keyed by GraphQL type names, then by requested field aliases (github.com/graphile/graphile-engine).

import { parseResolveInfo } from 'graphql-parse-resolve-info';

posts: (_, args, ctx, info) => {
  const tree = parseResolveInfo(info);
  const includeAuthor = 'author' in (tree?.fieldsByTypeName?.Post ?? {});
  return db.posts.findMany({
    limit: 50,
    include: { author: includeAuthor },
  });
}

C is B with a condition. It pays a small parsing cost per request to avoid paying the projection cost on queries that never ask for the field.

Step 4: Prove the fix with a call-count assertion

A structural fix is not done until a test pins the call count. Issue the query that triggered the fan-out and assert the number of data-source calls, not the response shape:

test('Post.author resolves in one batched call', async () => {
  const spy = jest.spyOn(db.users, 'findMany');
  await execute(`{ posts { author { name } } }`);   // 50 posts
  expect(spy).toHaveBeenCalledTimes(1);             // not 50
});

Assert on the batched call count. Asserting only that the response is correct passes just as happily with 50 queries as with one.

To confirm at the database layer, turn on the client's query log and count the emitted statements. Prisma exposes this through the client constructor:

const prisma = new PrismaClient({ log: ["query", "info", "warn", "error"] });

Each event carries the statement, its parameters, and its duration (prisma.io/docs/orm/prisma-client/observability-and-logging/logging). Repeated near-identical SELECT statements differing only by a bound id confirm the fan-out. One SELECT ... IN (...) confirms the batch.

Output shape

Report one finding per risky child field resolver, then a summary table.

## N+1 findings: `<resolver_path>`

**Scope:** <file>:<lines>

### Finding 1: `Post.author`

**Pattern:** N+1 via per-row database call.

**Location:** `resolvers/post.ts:42`

**Evidence:**

```typescript
Post: {
  author: (post) => db.users.findOne({ where: { id: post.authorId } }),
}
```

When `Query.posts` returns 50 posts, this resolver fires 50 times, giving
50 `findOne` calls.

**Recommended fix:** A (DataLoader). `author` is also resolved from
`Comment.author` and `Like.author`, so it is cross-cutting.

```typescript
// Per-request context setup:
const userLoader = new DataLoader<string, User | null>(async (ids) => {
  const users = await db.users.findMany({ where: { id: { in: [...ids] } } });
  return ids.map(id => users.find(u => u.id === id) ?? null);
});

// Resolver:
Post: { author: (post, _, ctx) => ctx.loaders.user.load(post.authorId) }
```

**Test:** issue `{ posts { author { name } } }` and assert
`db.users.findMany` was called once, not 50 times.

### Summary

| Field | Severity | Fix | Cross-cutting |
|---|---|---|---|
| `Post.author` | high | A: DataLoader | yes |
| `Post.comments` | medium | B: projection via include | no |
| `User.followers` | high | A: DataLoader plus cursor pagination | yes |

Severity here is a reporting convention, not a standard: rate a finding high when the fan-out is unbounded (a list field with no page-size cap) or crosses a network boundary, medium when the list is capped and the call stays in-process.

Worked examples

Two end-to-end scenarios - an ORM lazy-load silent case and a per-row HTTP call - each with its Step 2 classification, chosen fix, and report: references/examples.md.

Limitations

  • Structural, not measured. A resolver that could fan out but in practice always hits a warm cache is a false positive. Confirm against a query log or a resolver trace before filing it.
  • Dynamic dispatch is invisible. Resolvers wired through reflection, generated maps, or __resolveType do not read as a parent/child pair in source.
  • SQL-level fan-out is out of reach. ORM calls that look batched in application code can still emit per-row SQL. Pair this with a database-side slow-query analysis for that layer.
  • Fix A is not always right. If the field is loaded once per request, a loader adds indirection for no batching gain, and projection (Fix B) is simpler. Present the option and let the reviewer choose.
  • Aggregate N+1 across fields. Two resolvers that are each fine in isolation can combine into an explosion under one query. Detecting that needs execution traces, not source reading.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

コメント

ログインなしで書き込めます。使ってみた感想、質問、動かなかった環境などを気軽にどうぞ。

まだコメントはありません。最初のコメントを書いてみませんか。

送信するとすぐに公開されます。メールアドレスや電話番号などの個人情報は書かないでください。URL は 2 つまでです。

0 / 1000 文字

このフォームは reCAPTCHA で保護されており、Google のプライバシーポリシーと利用規約が適用されます。

同じリポジトリのスキル

Builds a CI gate that fails the build on **new** WCAG / a11y violations introduced by a PR while grandfathering pre-existing violations on a per-rule / per-page baseline. Aggregates verdicts from axe-core / pa11y / Lighthouse a11y / WAVE / IBM Equal Access scans. Use when a project has accumulated a11y debt and a strict "zero violations" gate would block every PR - the ratchet pattern lets the team ship while preventing regressions.

日本語の概要は準備中です。原文の説明を表示しています。

testland/qa132026年10月20日 更新

Workflow skill that builds an A/B-test validity checklist from an experiment proposal, walking the canonical design-correctness gates - pre-registered OEC/power/guardrails, randomization unit + SRM check, assignment integrity, telemetry, peeking discipline, novelty/primacy, post-experiment SRM re-check - into a per-experiment checklist + sign-off form. Use when launching, auditing, or governing an experiment. For pitfall mechanics (guardrails, peeking) see experiment-results-interpreter's references; to read an already-valid result use experiment-results-interpreter; for per-SDK harness tests use experiment-sdk-testing - this gates DESIGN, not SDK code.

日本語の概要は準備中です。原文の説明を表示しています。

testland/qa132026年10月20日 更新

The single install-flow skill: the reference contract for the PWA install flow (installability gate fields, `beforeinstallprompt` handshake, per-platform paths - Android WebAPK / iOS Share menu / Firefox no-op - and the `display-mode` post-install signal, in references/install-flow-reference.md) plus the build-an-X workflow that emits the Add-to-Home-Screen suite. Walks the four-stage timeline, emitting one test per gate cell per [web.dev/articles/install-criteria][install-criteria], the deferred-prompt → `prompt()` → `userChoice` chain per [web.dev/articles/customize-install][customize-install], the iOS Safari manual-metadata branch (`apple-touch-icon`) per [web.dev/learn/pwa/installation][learn-pwa], and the post-install `(display-mode: standalone)` MQ assertion. Output: a Playwright spec with per-stage cells plus a coverage matrix. Use when a PWA's manifest, icons, or install handler change, when install conversion drops at an unknown stage, or when triaging a flaky install assertion.

日本語の概要は準備中です。原文の説明を表示しています。

testland/qa132026年10月20日 更新

Build-an-X workflow that uses an LLM to map existing tests to spec sections - given a spec doc + the test suite, the LLM identifies which tests cover which sections, surfaces uncovered sections (gap), and recommends specific tests to add. Output is a coverage matrix per spec ID. Scope is mapping tests that already exist and naming the gaps, not authoring tests for new acceptance criteria. Use when a spec doc and a test suite both exist but nobody can say which requirements are actually covered - before a release sign-off, an audit, or a decision about where to spend the next round of test effort.

日本語の概要は準備中です。原文の説明を表示しています。

testland/qa132026年10月20日 更新

Generates tests from natural-language specs (acceptance criteria, user stories, requirements) using an LLM, with confidence scoring per test case (LLM self-assessment plus heuristics: assertion quality, naming, completeness), batching uncertain cases for human review, and integration with the team's existing test framework. Use when the user asks to generate unit tests from acceptance criteria, convert user stories to test cases, automate test creation from requirements, or augment a spec-driven test suite with AI-generated stubs that are then curated before merge.

日本語の概要は準備中です。原文の説明を表示しています。

testland/qa132026年10月20日 更新

Configures Allure Report (test-runner adapter install, `allure-results` directory wiring, `categories.json` for failure classification, `history-trend.json` retention via the copy-history-between-runs pattern), runs the Allure CLI to convert `allure-results` to a static HTML site, and uploads the report as a CI artifact. Use when the team needs richer test reporting than JUnit XML - step-level attachments, per-test history, retry tracking, and severity / epic / feature labeling across framework-agnostic adapters (pytest, Jest, JUnit, TestNG, NUnit, Mocha). As a rich static HTML report generator, it is the open-source alternative to the sunset ExtentReports (JVM/.NET per-test HTML narrative); for hosted cross-run flakiness analytics rather than a static per-run report use currents-integration.

日本語の概要は準備中です。原文の説明を表示しています。

testland/qa132026年10月20日 更新

testland のスキルをすべて見る

このスキルの問題を報告する