Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture docs/subsystems/3. Query Lanes.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ const plan = sql()

**Lowering strategy:**

- **LATERAL + json_agg:** The adapter lowers `includeMany` to a `LEFT JOIN LATERAL` with a subquery that uses `json_agg(json_build_object(...))` to aggregate child rows into a JSON array. The `ON` condition from the include moves into the `WHERE` clause of the lateral subquery.
- **LATERAL + json_agg:** The adapter lowers `includeMany` to a `LEFT JOIN LATERAL` with a subquery that uses `json_agg(jsonb_build_object(...))` to aggregate child rows into a JSON array. The `ON` condition from the include moves into the `WHERE` clause of the lateral subquery. Objects wider than 50 key/value pairs are emitted as multiple `jsonb_build_object(...)` calls merged with the JSONB `||` operator, since Postgres caps function arguments at 100.

- **Single statement:** This preserves the one query → one statement rule from [ADR 003](../adrs/ADR%20003%20-%20One%20Query%20One%20Statement.md) and avoids N+1 query patterns.

Expand Down
2 changes: 1 addition & 1 deletion packages/3-extensions/pgvector/test/rich-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('Postgres rich AST lowering', () => {
const lowered = adapter.lower(ast, { contract });

expect(lowered.sql).toContain('LEFT JOIN LATERAL');
expect(lowered.sql).toContain('json_agg(json_build_object');
expect(lowered.sql).toContain('json_agg(jsonb_build_object');
expect(lowered.sql).toContain('ORDER BY "post_rows"."title" ASC');
expect(lowered.sql).toContain('LIMIT 10 OFFSET 5');
expect(lowered.sql).toContain('WHERE "user"."id" = $1');
Expand Down
10 changes: 6 additions & 4 deletions packages/3-targets/6-adapters/postgres/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Provide PostgreSQL-specific adapter implementation, codecs, and capabilities. En

- **Adapter Implementation**: Implement `Adapter` SPI for PostgreSQL
- Lower SQL ASTs to PostgreSQL dialect SQL
- Render JSON aggregation (`json_agg`, `json_build_object`) and scalar subqueries
- Render JSON aggregation (`json_agg`, `jsonb_build_object`) and scalar subqueries
- Advertise PostgreSQL capabilities (`lateral`, `jsonAgg`)
- Normalize PostgreSQL EXPLAIN output
- Map PostgreSQL errors to `RuntimeError` envelope
Expand Down Expand Up @@ -90,7 +90,7 @@ flowchart TD
- Main adapter implementation
- Lowers SQL ASTs to PostgreSQL SQL
- Renders joins (INNER, LEFT, RIGHT, FULL, LATERAL) with ON conditions
- Renders JSON aggregation (`json_agg`, `json_build_object`) and scalar subqueries
- Renders JSON aggregation (`json_agg`, `jsonb_build_object`) and scalar subqueries
- Renders DML operations (INSERT, UPDATE, DELETE) with RETURNING clauses
- Advertises PostgreSQL capabilities (`lateral`, `jsonAgg`, `returning`)
- Maps PostgreSQL errors to `RuntimeError`
Expand Down Expand Up @@ -209,14 +209,16 @@ See `docs/reference/capabilities.md` and `docs/architecture docs/subsystems/5. A

The renderer lowers JSON-aggregation AST nodes to PostgreSQL's `json_agg`:

- `json_agg(json_build_object(...))` aggregates a row set into a JSON array of objects
- `json_agg(jsonb_build_object(...))` aggregates a row set into a JSON array of objects
- A scalar subquery (`SubqueryExpr`) in the SELECT list correlates against the outer row through its WHERE clause
- When the subquery carries an inner `ORDER BY` and `LIMIT`, its rows are wrapped in an inner SELECT, then aggregated with `json_agg(row_to_json(sub.*))`

PostgreSQL caps function arguments at 100, so `jsonb_build_object` is emitted in chunks of at most 50 key/value pairs per call (100 arguments) and the per-chunk objects are merged with the JSONB `||` concatenation operator. For 50 or fewer pairs a single `jsonb_build_object(...)` call is emitted with no `||`. JSONB (not plain `json`) is required because `||` concatenation is only defined on `jsonb`.

**Example SQL Output:**
```sql
SELECT "user"."id" AS "id", (
SELECT json_agg(json_build_object('id', "post"."id", 'title', "post"."title")) AS "posts"
SELECT json_agg(jsonb_build_object('id', "post"."id", 'title', "post"."title")) AS "posts"
FROM "post"
WHERE "user"."id" = "post"."userId"
) AS "posts"
Expand Down
34 changes: 24 additions & 10 deletions packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,21 +644,35 @@ function renderWindowFuncExpr(
return `${fn}(${args}) OVER (${over})`;
}

/**
* Maximum key/value pairs emitted in a single `jsonb_build_object` call. Postgres caps function
* arguments at 100, so 50 key/value pairs (100 args) is the largest safe batch; wider objects are
* split across multiple `jsonb_build_object(...)` calls and merged with the JSONB `||`
* concatenation operator (which plain `json` does not support).
*/
const JSONB_BUILD_OBJECT_MAX_PAIRS = 50;

function renderJsonObjectExpr(
expr: JsonObjectExpr,
contract: PostgresContract,
pim: ParamIndexMap,
): string {
const args = expr.entries
.flatMap((entry): [string, string] => {
const key = `'${escapeLiteral(entry.key)}'`;
if (entry.value.kind === 'literal') {
return [key, renderLiteral(entry.value)];
}
return [key, renderExpr(entry.value, contract, pim)];
})
.join(', ');
return `json_build_object(${args})`;
const renderedPairs = expr.entries.map((entry): [string, string] => {
const key = `'${escapeLiteral(entry.key)}'`;
if (entry.value.kind === 'literal') {
return [key, renderLiteral(entry.value)];
}
return [key, renderExpr(entry.value, contract, pim)];
});
if (renderedPairs.length === 0) {
return 'jsonb_build_object()';
}
const chunks: string[] = [];
for (let i = 0; i < renderedPairs.length; i += JSONB_BUILD_OBJECT_MAX_PAIRS) {
const slice = renderedPairs.slice(i, i + JSONB_BUILD_OBJECT_MAX_PAIRS);
chunks.push(`jsonb_build_object(${slice.flat().join(', ')})`);
}
return chunks.join(' || ');
}

function renderOrderByItems(
Expand Down
47 changes: 46 additions & 1 deletion packages/3-targets/6-adapters/postgres/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ const contract = new SqlContractSerializer().deserializeContract({
domain: applicationDomainOf({ models: {} }),
}) as PostgresContract;

function jsonObjectEntries(count: number) {
return Array.from({ length: count }, (_, i) =>
JsonObjectExpr.entry(`f${i + 1}`, LiteralExpr.of(i + 1)),
);
}

function expectedJsonbBuildObject(count: number): string {
const chunks: string[] = [];
for (let i = 0; i < count; i += 50) {
const end = Math.min(i + 50, count);
const args: string[] = [];
for (let j = i; j < end; j++) {
args.push(`'f${j + 1}'`, `${j + 1}`);
}
chunks.push(`jsonb_build_object(${args.join(', ')})`);
}
return chunks.join(' || ');
}

describe('Postgres adapter', () => {
const adapter = createPostgresAdapter();

Expand All @@ -110,7 +129,7 @@ describe('Postgres adapter', () => {

const lowered = adapter.lower(ast, { contract, params: [] });

expect(lowered.sql).toContain('json_build_object');
expect(lowered.sql).toContain('jsonb_build_object');
expect(lowered.sql).toContain(
'(SELECT "post"."id" AS "id" FROM "post" WHERE "post"."userId" = "user"."id") AS "firstPostId"',
);
Expand Down Expand Up @@ -572,4 +591,30 @@ describe('Postgres adapter', () => {
const sql = adapter.lower(ast, { contract: publicContract, params: [] }).sql;
expect(sql).toBe('SELECT "user"."id" AS "id" FROM "public"."user"');
});

it('renders a single jsonb_build_object call with no || for 50 or fewer entries', () => {
const ast = SelectAst.from(TableSource.named('user')).withProjection([
ProjectionItem.of('payload', JsonObjectExpr.fromEntries(jsonObjectEntries(50))),
]);
const sql = adapter.lower(ast, { contract, params: [] }).sql;
expect(sql).toBe(`SELECT ${expectedJsonbBuildObject(50)} AS "payload" FROM "user"`);
expect((sql.match(/jsonb_build_object\(/g) ?? []).length).toBe(1);
expect(sql).not.toContain('||');
});

it('chunks jsonb_build_object into 50-pair batches merged with || past 50 entries', () => {
const ast = SelectAst.from(TableSource.named('user')).withProjection([
ProjectionItem.of('payload', JsonObjectExpr.fromEntries(jsonObjectEntries(110))),
]);
const sql = adapter.lower(ast, { contract, params: [] }).sql;
expect(sql).toBe(`SELECT ${expectedJsonbBuildObject(110)} AS "payload" FROM "user"`);
const calls = sql.match(/jsonb_build_object\([^)]*\)/g) ?? [];
expect(calls.length).toBe(3);
expect(sql).toContain(' || ');
for (const call of calls) {
const argCount = (call.match(/,/g) ?? []).length + 1;
expect(argCount % 2).toBe(0);
expect(argCount / 2).toBeLessThanOrEqual(50);
}
});
});
Loading