Skip to content

Nested shapes (links) — AQL

Shapes can include linked types. Axel compiles nested shapes into a single SQL query using row_to_json or json_agg — no N+1.

Returns a JSON object.

select Post {
id,
title,
author: {
id,
email
}
};
SELECT
p.id AS id,
p.title AS title,
(SELECT row_to_json(u_author_sub)
FROM (
SELECT u_author.id AS id, u_author.email AS email
FROM "user" u_author
WHERE u_author.id = p.author
LIMIT 1
) u_author_sub) AS author
FROM "post" p;

Returns a JSON array. Empty results return [] rather than null.

select Post {
id,
title,
likes: {
id,
email
}
};
SELECT
p.id AS id,
p.title AS title,
(SELECT COALESCE(json_agg(row_to_json(u_likes_sub)), '[]')
FROM (
SELECT u_likes.id AS id, u_likes.email AS email
FROM "post_likes" jt_likes
JOIN "user" u_likes ON u_likes.id = jt_likes.user
WHERE jt_likes.post = p.id
) u_likes_sub) AS likes
FROM "post" p;

A link sub-shape is a full shape: it may itself select nested links, computed fields, and the * splat — to any depth. Each link nests another correlated JSON subquery inside its parent.

select Application {
id,
project: {
id,
organization: {
id,
name
}
}
};

The same paths work in a filter: a multi-step path resolves through the intervening links down to the target column, so .project.organization.id filters against project’s organization FK without an explicit join.

multi select Application {
*,
project: { id, organization: { id } }
}
filter .project.organization.owner = $user<uuid>
and .project.organization.id = $organization<uuid>?;

By default Axel compiles nested shapes using correlated subqueries in the SELECT projection (the query strategy). You can switch to LEFT JOIN LATERAL instead — either globally in axel.yaml or per-query with a directive.

Each nested link becomes a correlated scalar subquery inside the SELECT list:

  • Single links → row_to_json(...)
  • Multi links → COALESCE(json_agg(...), '[]')

Best for most workloads. Keeps the outer query simple and lets the planner evaluate each subquery only for the rows it needs.

Each nested link becomes a LEFT JOIN LATERAL in the FROM clause:

@rel_load_strategy join
select Post {
id,
title,
author: { id, email },
likes: { id, email }
};
SELECT p.id, p.title, author.author, likes.likes
FROM "post" p
LEFT JOIN LATERAL (
SELECT row_to_json(u_sub) AS author
FROM (SELECT id, email FROM "user" WHERE id = p.author LIMIT 1) u_sub
) author ON true
LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(row_to_json(u_sub)), '[]') AS likes
FROM (
SELECT u.id, u.email FROM "post_likes" jt
JOIN "user" u ON u.id = jt.user
WHERE jt.post = p.id
) u_sub
) likes ON true;

Prefer join when the planner benefits from seeing all lateral joins together — for instance, when you filter or order by columns from nested relations, or when your PostgreSQL version handles lateral joins more efficiently for your data shape.

Per-query — use the @rel_load_strategy directive at the top of the .aql file:

@rel_load_strategy join

Globally — set it in axel.yaml so all queries in the project use it:

rel-load-strategy: join

The per-query directive takes precedence over the global setting. See Directives for the full list of query-level options.