WebPipe 2.0 introduces a fully integrated GraphQL runtime, allowing you to define schemas, map resolvers to pipelines, and execute queries efficiently.
Use the graphqlSchema block in your .wp files to define your GraphQL schema using standard Schema Definition Language (SDL).
graphqlSchema = `
type User {
id: ID!
name: String!
email: String!
}
type Query {
users: [User!]!
user(id: ID!): User
}
type Mutation {
createUser(name: String!, email: String!): User!
}
`Resolvers are mapped to WebPipe pipelines using the query and mutation keywords. Each resolver pipeline receives the GraphQL arguments and parent object in its input.
# Resolver for Query.users
query users =
|> pg: `SELECT * FROM users`
|> jq: `.data.rows`
# Resolver for Query.user(id: ID!)
query user =
|> jq: `{ sqlParams: [.id] }`
|> pg: `SELECT * FROM users WHERE id = $1`
|> jq: `.data.rows[0]`
# Resolver for Mutation.createUser
mutation createUser =
|> auth: "required"
|> jq: `{ sqlParams: [.name, .email] }`
|> pg: `INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *`
|> jq: `.data.rows[0]`
To expose your GraphQL API, use the graphql middleware in a route. This middleware executes the query against your defined schema and resolvers.
POST /graphql
|> auth: "optional"
|> graphql: `query` # The config here is usually dynamic or passed from input
Typically, you'll want to accept the query from the HTTP request body:
POST /graphql
|> auth: "optional"
# Extract query and variables from request body
|> jq: `.graphqlParams = .body`
# Execute GraphQL
|> graphql: .body.query
(Note: The actual graphql middleware implementation handles the execution details. The example above simplifies the common pattern.)
Use @result tags for cleaner result naming:
GET /dashboard
|> graphql: `query { todos { id title } }` @result(todos)
|> graphql: `query { currentUser { name email } }` @result(user)
|> jq: `{
todos: .data.todos.data.todos,
user: .data.user.data.currentUser
}`
Execute multiple GraphQL queries in parallel:
GET /async-dashboard
|> graphql: `query { users { id name } }` @async(usersQuery) @result(users)
|> graphql: `query { stats { total active } }` @async(statsQuery) @result(stats)
|> graphql: `query { posts(limit: 5) { id title } }` @async(postsQuery) @result(posts)
|> join: `usersQuery, statsQuery, postsQuery`
|> jq: `{
users: .async.usersQuery.data.users.data.users,
stats: .async.statsQuery.data.stats.data.stats,
posts: .async.postsQuery.data.posts.data.posts
}`
Execute async operations within GraphQL resolvers:
query userWithPosts =
# Fetch user from database
|> jq: `{ sqlParams: [.id] }`
|> pg: `SELECT * FROM users WHERE id = $1` @result(user)
# Fetch user's posts in parallel
|> pg: `SELECT * FROM posts WHERE user_id = $1` @async(posts) @result(userPosts)
# Wait for posts query
|> join: `posts`
# Combine results
|> jq: `
.data.user.rows[0] + {
posts: .async.posts.data.userPosts.rows
}
`
Conditionally execute resolver logic:
query adminData =
# Only execute if user is admin
|> pg: `SELECT * FROM sensitive_data` @guard(`.user.role == "admin"`)
|> jq: `.data.rows`
query regularData =
# Always executes
|> pg: `SELECT * FROM public_data`
|> jq: `.data.rows`
Use dynamic conditions in resolvers:
query conditionalData =
|> lua: `
setWhen("is_premium", request.user and request.user.premium == true)
return request
`
|> dispatch
case @when(is_premium):
|> pg: `SELECT * FROM premium_content`
default:
|> pg: `SELECT * FROM basic_content`
|> jq: `.data.rows`
Resolve nested fields with separate queries:
# Parent resolver
query user =
|> jq: `{ sqlParams: [.id] }`
|> pg: `SELECT * FROM users WHERE id = $1`
|> jq: `.data.rows[0]`
# Field resolver for user.posts
query userPosts =
# .parent contains the parent User object
|> jq: `{ sqlParams: [.parent.id] }`
|> pg: `SELECT * FROM posts WHERE user_id = $1`
|> jq: `.data.rows`
# Field resolver for user.company
query userCompany =
|> jq: `{ sqlParams: [.parent.company_id] }`
|> pg: `SELECT * FROM companies WHERE id = $1`
|> jq: `.data.rows[0]`
mutation createPost =
# Validate input
|> validate: `{
title: string(3..200),
body: string(10..10000),
userId: number
}`
|> result
ok(200):
# Insert post
|> pg([.title, .body, .userId]): `
INSERT INTO posts (title, body, user_id)
VALUES ($1, $2, $3)
RETURNING *
`
|> jq: `.data.rows[0]`
validationError(400):
# Return validation error to GraphQL
|> jq: `{ errors: .errors }`
mutation deleteUser =
|> auth: "required"
|> result
ok(200):
# Check if user is admin
|> lua: `
setWhen("is_admin", request.user.role == "admin")
return request
`
|> dispatch
case @when(is_admin):
|> pg([.id]): `DELETE FROM users WHERE id = $1`
|> jq: `{ success: true, deletedId: .id }`
default:
|> jq: `{ errors: [{ message: "Unauthorized" }] }`
authRequired(401):
|> jq: `{ errors: [{ message: "Authentication required" }] }`
query dashboard =
# Fetch from database
|> pg: `SELECT * FROM users` @async(dbUsers) @result(users)
# Fetch from external API
|> fetch("https://api.example.com/analytics") @async(analytics) @result(stats)
# Execute another GraphQL query
|> graphql: `query { posts { id title } }` @async(postsQuery) @result(posts)
# Wait for all
|> join: `dbUsers, analytics, postsQuery`
# Aggregate results
|> jq: `{
users: .async.dbUsers.data.users.rows,
analytics: .async.analytics.data.stats.response,
posts: .async.postsQuery.data.posts.data.posts
}`
query userProfile =
# Fetch basic user data
|> jq: `{ sqlParams: [.id] }`
|> pg: `SELECT * FROM users WHERE id = $1` @result(user)
# Conditionally fetch sensitive data
|> lua: `
setWhen("is_owner", request.user and request.user.id == request.id)
return request
`
# Only fetch sensitive data if user is viewing their own profile
|> pg([.id]): `SELECT email, phone FROM user_private WHERE user_id = $1` @when(is_owner) @result(private)
# Combine results
|> jq: `
.data.user.rows[0] +
(if .data.private then { email: .data.private.rows[0].email, phone: .data.private.rows[0].phone } else {} end)
`
query batchUsers =
# Receives array of IDs in .ids
|> jq: `{ sqlParams: .ids }`
|> pg: `SELECT * FROM users WHERE id = ANY($1::int[])`
|> jq: `.data.rows`
query userOrNull =
|> jq: `{ sqlParams: [.id] }`
|> pg: `SELECT * FROM users WHERE id = $1`
|> result
ok(200):
|> jq: `
if .data.rowCount > 0 then
.data.rows[0]
else
null
end
`
sqlError(500):
|> jq: `{ errors: [{ message: "Database error", details: .errors }] }`
mutation riskyOperation =
|> pg([.input]): `INSERT INTO data VALUES ($1) RETURNING *`
|> result
ok(200):
|> jq: `.data.rows[0]`
sqlError(500):
# Return error in GraphQL error format
|> jq: `{
errors: [{
message: .errors[0].message,
extensions: {
code: "DATABASE_ERROR",
sqlstate: .errors[0].sqlstate
}
}]
}`
query expensiveStats =
|> cache: `ttl: 300, keyTemplate: "stats-global"`
|> pg: `
SELECT
COUNT(*) as total_users,
AVG(age) as avg_age,
MAX(created_at) as last_signup
FROM users
`
|> jq: `.data.rows[0]`
Instead of sequential field resolution, use async:
query richUserProfile =
|> jq: `{ userId: .id }`
# Fetch all related data in parallel
|> pg([.userId]): `SELECT * FROM users WHERE id = $1` @async(user) @result(userData)
|> pg([.userId]): `SELECT * FROM posts WHERE user_id = $1` @async(posts) @result(userPosts)
|> pg([.userId]): `SELECT * FROM comments WHERE user_id = $1` @async(comments) @result(userComments)
|> join: `user, posts, comments`
# Combine all data
|> jq: `
.async.user.data.userData.rows[0] + {
posts: .async.posts.data.userPosts.rows,
comments: .async.comments.data.userComments.rows,
postCount: .async.posts.data.userPosts.rowCount,
commentCount: .async.comments.data.userComments.rowCount
}
`
Always name your resolver results:
query user =
|> pg: `SELECT * FROM users WHERE id = $1` @result(user)
|> jq: `.data.user.rows[0]`
When resolver needs multiple data sources:
query dashboard =
|> pg: `SELECT * FROM users` @async(users)
|> pg: `SELECT * FROM posts` @async(posts)
|> join: `users, posts`
Always validate input in mutation resolvers:
mutation createUser =
|> validate: `{ name: string(3..100), email: email }`
|> result
ok(200):
# ... mutation logic ...
Protect sensitive resolvers with guards:
query adminStats =
|> pg: `SELECT * FROM admin_stats` @guard(`.user.role == "admin"`)
Format errors correctly for GraphQL:
|> result
sqlError(500):
|> jq: `{ errors: [{ message: .errors[0].message }] }`
DataLoaders solve the N+1 query problem in GraphQL by batching multiple resolver calls into a single database query.
Without DataLoaders, fetching nested data causes N+1 queries:
# Without DataLoader: 1 + N queries
# - 1 query to fetch all teams
# - N queries to fetch employees for each team (one per team)
query teams =
|> pg: `SELECT * FROM teams`
|> jq: `.data.rows`
query employees =
# Called once per team! (N+1 problem)
|> pg([.teamId]): `SELECT * FROM employees WHERE team_id = $1`
|> jq: `.data.rows`
Problem: Fetching 10 teams with employees = 11 database queries (1 for teams, 10 for employees)
Use the loader middleware to batch resolver calls:
# Define a loader pipeline that handles batched requests
pipeline EmployeesByTeamLoader =
# Receives:
# - .keys: [1, 2, 3] (batched team IDs)
# - .args: {limit: 5} (GraphQL field arguments)
# - .context: {...} (request context)
|> pg([(.keys | map(tostring) | join(",")), (.args.limit // 10)]): `!raw
SELECT
json_object_agg(
sub.team_id,
sub.employees
)
FROM (
SELECT
team_id,
json_agg(
json_build_object(
'id', id::text,
'name', name,
'email', email,
'teamId', team_id::text
)
) AS employees
FROM (
SELECT
id,
name,
email,
team_id,
ROW_NUMBER() OVER (
PARTITION BY team_id
ORDER BY id
) AS rn
FROM employees
WHERE team_id::text = ANY(string_to_array($1, ','))
) ranked
WHERE ranked.rn <= $2
GROUP BY team_id
) sub
`
# Use loader in nested resolver
resolver Team.employees =
|> loader(.parent.id): EmployeesByTeamLoader
Solution: Fetching 10 teams with employees = 2 database queries (1 for teams, 1 batched for all employees)
A loader pipeline must:
- Accept batched keys in
.keysarray - Return a keyed object mapping each key to its result
- Handle field arguments from
.args - Access request context via
.context
Input Structure:
{
"keys": [1, 2, 3],
"args": { "limit": 5 },
"context": { "user": {...}, "headers": {...} }
}Output Structure:
{
"1": [{ "id": 10, "name": "Alice" }],
"2": [{ "id": 20, "name": "Bob" }],
"3": []
}The loader middleware syntax:
resolver Parent.field =
|> loader(<key-expression>): <loader-pipeline>
Example:
resolver Team.employees =
|> loader(.parent.id): EmployeesByTeamLoader
How it works:
- GraphQL resolver is called for each team
- Loader collects all
.parent.idvalues (batching phase) - Loader calls pipeline once with
.keys = [1, 2, 3] - Loader distributes results back to each resolver call
# GraphQL Schema
graphqlSchema = `
type Employee {
id: ID!
name: String!
email: String!
teamId: Int!
}
type Team {
id: ID!
name: String!
employees(limit: Int): [Employee!]!
}
type Query {
teams: [Team!]!
team(id: ID!): Team
}
`
# Query resolvers
query teams =
|> pg: `SELECT id, name FROM teams`
|> jq: `.data.rows`
query team =
|> pg([.id]): `SELECT id, name FROM teams WHERE id = $1`
|> jq: `.data.rows[0]`
# Loader pipeline for employees
pipeline EmployeesByTeamLoader =
|> pg([(.keys | map(tostring) | join(",")), (.args.limit // 10)]): `!raw
SELECT
json_object_agg(
sub.team_id,
sub.employees
)
FROM (
SELECT
team_id,
json_agg(
json_build_object(
'id', id::text,
'name', name,
'email', email,
'teamId', team_id::text
)
) AS employees
FROM (
SELECT
id,
name,
email,
team_id,
ROW_NUMBER() OVER (
PARTITION BY team_id
ORDER BY id
) AS rn
FROM employees
WHERE team_id::text = ANY(string_to_array($1, ','))
) ranked
WHERE ranked.rn <= $2
GROUP BY team_id
) sub
`
# Nested resolver with DataLoader
resolver Team.employees =
|> loader(.parent.id): EmployeesByTeamLoader
# Use it in a query
GET /test-teams-with-employees
|> graphql: `
query {
teams {
id
name
employees(limit: 5) {
id
name
email
}
}
}
`
Pass GraphQL field arguments to the loader:
# Schema with arguments
graphqlSchema = `
type Team {
employees(limit: Int, status: String): [Employee!]!
}
`
# Loader handles arguments
pipeline EmployeesByTeamLoader =
# .args contains { limit: 5, status: "active" }
|> pg([
(.keys | join(",")),
(.args.limit // 10),
(.args.status // "active")
]): `
SELECT json_object_agg(team_id, employees)
FROM (
SELECT team_id, json_agg(...) AS employees
FROM employees
WHERE team_id = ANY(string_to_array($1, ','))
AND status = $3
LIMIT $2
GROUP BY team_id
) sub
`
resolver Team.employees =
|> loader(.parent.id): EmployeesByTeamLoader
Use .context for authorization:
pipeline EmployeesByTeamLoader =
# Check user permissions in context
|> lua: `
if not context.user or context.user.role ~= "admin" then
return { errors = {{ type = "authError", message = "Unauthorized" }} }
end
return request
`
|> result
ok(200):
|> pg([(.keys | join(","))]): `
SELECT json_object_agg(team_id, employees)
FROM (...)
`
authError(403):
|> jq: `{ errors: .errors }`
Define multiple loaders for different relationships:
pipeline EmployeesByTeamLoader =
# ... as before
pipeline ProjectsByTeamLoader =
|> pg([(.keys | join(","))]): `
SELECT json_object_agg(team_id, projects)
FROM (
SELECT team_id, json_agg(...) AS projects
FROM projects
WHERE team_id = ANY(string_to_array($1, ','))
GROUP BY team_id
) sub
`
resolver Team.employees =
|> loader(.parent.id): EmployeesByTeamLoader
resolver Team.projects =
|> loader(.parent.id): ProjectsByTeamLoader
You can also call loader pipelines directly in routes:
GET /test-teams-with-employees-manual
# Fetch all teams
|> pg: `SELECT id, name FROM teams`
# Prepare batched loader call
|> jq: `.data.rows | map(.id) as $keys | {
keys: $keys,
args: { limit: 5 }
}`
# Call loader pipeline
|> pipeline: EmployeesByTeamLoader
# Combine results
|> jq: `{
teams: .keys | map({
id: .,
employees: $result[. | tostring] // []
})
}`
Test loader pipelines with batched inputs:
describe "DataLoader N+1 Prevention"
it "fetches teams with employees using batching"
when calling GET /test-teams-with-employees
then status is 200
and output `.data.teams | type` equals `"array"`
and output `.data.teams[0].name | type` equals `"string"`
and output `.data.teams[0].employees | type` equals `"array"`
Without DataLoader:
Query: teams { employees { name } }
Execution:
- SELECT * FROM teams (1 query)
- SELECT * FROM employees WHERE team_id = 1 (1 query)
- SELECT * FROM employees WHERE team_id = 2 (1 query)
- SELECT * FROM employees WHERE team_id = 3 (1 query)
Total: 4 queries for 3 teams
With DataLoader:
Query: teams { employees { name } }
Execution:
- SELECT * FROM teams (1 query)
- SELECT * FROM employees WHERE team_id IN (1,2,3) (1 query)
Total: 2 queries for 3 teams
| Scenario | Without DataLoader | With DataLoader |
|---|---|---|
| 10 teams | 11 queries | 2 queries |
| 100 teams | 101 queries | 2 queries |
| 1000 teams | 1001 queries | 2 queries |
The performance benefit increases dramatically with more parent entities.
Chain DataLoaders for deeply nested queries:
# Loader for posts by user ID
pipeline PostsByUserLoader =
|> pg([(.keys | map(tostring) | join(","))]): `!raw
SELECT json_object_agg(sub.user_id, sub.posts)
FROM (
SELECT user_id, json_agg(json_build_object('id', id, 'title', title)) as posts
FROM posts
WHERE user_id::text = ANY(string_to_array($1, ','))
GROUP BY user_id
) sub
`
# Loader for comments by post ID
pipeline CommentsByPostLoader =
|> pg([(.keys | map(tostring) | join(","))]): `!raw
SELECT json_object_agg(sub.post_id, sub.comments)
FROM (
SELECT post_id, json_agg(json_build_object('id', id, 'text', text)) as comments
FROM comments
WHERE post_id::text = ANY(string_to_array($1, ','))
GROUP BY post_id
) sub
`
# Nested resolvers
resolver User.posts =
|> loader(.parent.id): PostsByUserLoader
resolver Post.comments =
|> loader(.parent.id): CommentsByPostLoader
Query with 3 levels of nesting:
query {
users { # 1 query
posts { # 1 batched query for all users
comments { # 1 batched query for all posts
text
}
}
}
}
# Total: 3 queries (instead of potentially 1000s)Return null for missing relationships:
resolver Post.author =
|> jq: `if .parent.authorId then . else { result: null } end`
|> if
|> jq: `.result == null`
then:
|> jq: `null`
else:
|> loader(.parent.authorId): UserLoader
end
Use compound keys for complex relationships:
# Loader that uses composite keys (type:id format)
pipeline ItemsByCompositeKeyLoader =
|> jq: `{
parsed: (.keys | map(split(":")) | map({type: .[0], id: .[1]})),
keys: .keys
}`
|> pg([.parsed]): `
SELECT
item_type || ':' || item_id as composite_key,
json_build_object('id', id, 'name', name, 'type', item_type) as item
FROM items
WHERE (item_type, item_id::text) IN (
SELECT elem->>'type', elem->>'id'
FROM jsonb_array_elements($1::jsonb) elem
)
`
|> jq: `
.data.rows | reduce .[] as $row (
{};
. + { ($row.composite_key): $row.item }
)
`
# Resolver with composite key
resolver Order.item =
|> jq: `.parent.itemType + ":" + (.parent.itemId | tostring)`
|> loader(.): ItemsByCompositeKeyLoader
Your loader is returning an array instead of an object. Make sure to convert rows to a map:
# ❌ Wrong - returns array
|> pg: `SELECT * FROM users WHERE id = ANY($1)`
|> jq: `.data.rows`
# ✅ Correct - returns map keyed by ID
|> pg: `SELECT * FROM users WHERE id = ANY($1)`
|> jq: `
.data.rows | reduce .[] as $row (
{};
. + { ($row.id | tostring): $row }
)
`
Best practice: Use PostgreSQL's json_object_agg to build the map in the database:
|> pg([(.keys | map(tostring) | join(","))]): `!raw
SELECT json_object_agg(id::text, row_to_json(u.*))
FROM users u
WHERE id::text = ANY(string_to_array($1, ','))
`
Make sure you're using the resolver keyword, not query:
# ❌ Won't batch - runs for each parent
query Post.author =
|> pg([.parent.authorId]): `SELECT * FROM users WHERE id = $1`
# ✅ Will batch - collects all keys first
resolver Post.author =
|> loader(.parent.authorId): UserLoader
Check that your GraphQL schema defines the field:
# Schema must define the nested field
graphql {
type Post {
id: ID!
title: String!
author: User # ← Must be defined here
}
type User {
id: ID!
name: String!
}
}
# Then define the resolver
resolver Post.author =
|> loader(.parent.authorId): UserLoader
If .parent.authorId is undefined, check:
- The parent query returns objects with
authorIdfield - Field name matches exactly (case-sensitive)
- The parent resolver is working correctly
If some keys don't have data, return null or empty array:
pipeline UserLoader =
|> pg([(.keys | map(tostring) | join(","))]): `!raw
SELECT json_object_agg(id::text, row_to_json(u.*))
FROM users u
WHERE id::text = ANY(string_to_array($1, ','))
`
|> jq: `
# Build complete map with nulls for missing keys
.keys | reduce .[] as $key (
.result // {};
if has($key | tostring) then . else . + {($key | tostring): null} end
)
`
- Use DataLoaders for all nested resolvers that query databases
- Return keyed objects mapping each key to its result (use
json_object_agg) - Handle missing keys by returning empty arrays/null in the result map
- Convert IDs to strings - JSON object keys must be strings
- Use field arguments for filtering, limiting, sorting via
.args - Check permissions in loader pipeline using
.context - Test with multiple entities to verify batching works
- Monitor query logs to confirm N+1 queries are eliminated
- Use
!rawflag when PostgreSQL returns pre-formatted JSON - Keep loaders simple - complex logic belongs in the parent resolver
- GraphQL Middleware - GraphQL middleware usage
- Concurrency - Async execution patterns
- Flow Control - @guard, @when, dispatch
- Routes & Pipelines - @result and named results
- DSL Syntax - Complete syntax reference