From ac99826e4b4aa256615eaf9d2718488f244c7860 Mon Sep 17 00:00:00 2001 From: Gremlin Date: Thu, 16 Jul 2026 17:24:16 +0000 Subject: [PATCH] PRS-215: emit jsonb_build_object with 50-pair chunking in Postgres adapter renderJsonObjectExpr now emits jsonb_build_object instead of json_build_object, batching entries at 50 key/value pairs per call (100 args, Postgres' function-call argument cap) and merging per-chunk objects with the JSONB || concatenation operator. Objects of 50 or fewer pairs render a single call with no concatenation. Updates the adapter README prose and the Query Lanes subsystem doc, fixes the pgvector rich-adapter assertion that still expected json_build_object, and adds adapter tests covering the <=50 and >50-field chunking cases. Refs: PRS-215, prisma-engines#4555 Signed-off-by: Gremlin --- .../subsystems/3. Query Lanes.md | 2 +- .../pgvector/test/rich-adapter.test.ts | 2 +- .../3-targets/6-adapters/postgres/README.md | 10 ++-- .../postgres/src/core/sql-renderer.ts | 34 ++++++++++---- .../6-adapters/postgres/test/adapter.test.ts | 47 ++++++++++++++++++- 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/docs/architecture docs/subsystems/3. Query Lanes.md b/docs/architecture docs/subsystems/3. Query Lanes.md index 59d5159ad9..268ef00722 100644 --- a/docs/architecture docs/subsystems/3. Query Lanes.md +++ b/docs/architecture docs/subsystems/3. Query Lanes.md @@ -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. diff --git a/packages/3-extensions/pgvector/test/rich-adapter.test.ts b/packages/3-extensions/pgvector/test/rich-adapter.test.ts index 8caf871ef8..4facc655cd 100644 --- a/packages/3-extensions/pgvector/test/rich-adapter.test.ts +++ b/packages/3-extensions/pgvector/test/rich-adapter.test.ts @@ -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'); diff --git a/packages/3-targets/6-adapters/postgres/README.md b/packages/3-targets/6-adapters/postgres/README.md index 67d683f8ab..ad4dd1f89f 100644 --- a/packages/3-targets/6-adapters/postgres/README.md +++ b/packages/3-targets/6-adapters/postgres/README.md @@ -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 @@ -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` @@ -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" diff --git a/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts b/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts index 379efc75e9..8c47a492e1 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts @@ -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( diff --git a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts index 7c2595a03c..fc337af1f8 100644 --- a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts @@ -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(); @@ -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"', ); @@ -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); + } + }); });