From f5b34bdb275a5527599e7b5a79d3c4c7881df233 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 1 Sep 2026 17:25:27 +0000 Subject: [PATCH 1/2] fix(target-postgres): resolve inferrable array parameters against the target column instead of always casting renderTypedParam short-circuited on `|| many`, so every array parameter got an explicit `$N::[]` cast regardless of whether its element type was inferrable. Against a native Postgres enum array column that produced `$1::text[]`, which fails with `42804: column "moods" is of type "Mood"[] but expression is of type text[]` -- the column was unwritable through the ORM. Change `|| many` to `|| (many && forceArrayCast)`: an array whose element type is inferrable now reaches the bare-`$N` path and resolves against the target column, exactly as a scalar `text` parameter already did. `forceArrayCast` stays true at the three bare function-call-argument positions (FunctionSource, FunctionCallExpr, WindowFuncExpr) where a polymorphic function like `unnest(anyarray)` cannot resolve an untyped argument -- non-polymorphic scalar function args (`concat($1, ...)`) were never affected. Fixes #30165 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UcqoY3CKfnubdZt5YQk2Rq Signed-off-by: Steven McClankerton --- .../postgres/src/core/sql-renderer.ts | 32 +++- ...array-inferrable-write.integration.test.ts | 145 ++++++++++++++++++ .../test/sql-renderer.cast-policy.test.ts | 73 ++++++++- 3 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts 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 24579d30b616..a69f652b60c7 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 @@ -56,6 +56,14 @@ import type { PostgresContract } from './types'; * `json` / `jsonb` are intentionally excluded despite being Postgres builtins: their operator overloads make context inference unreliable in expression positions (e.g. `$1 -> 'key'` is ambiguous between the two). * * Spellings match the target descriptors' `nativeTypeFor` results, not the `udt_name` abbreviations that ADR 205 used as illustrative shorthand. The registry-based cast policy compares against these strings directly. + * + * Array (`many: true`) parameters are position-dependent (issue #30165). In comparison/assignment position (`col = $1`, `INSERT ... VALUES ($1)`) they follow this same inferrable-type rule as scalars and emit bare `$N` — Postgres derives the type from the target column, same as it does for a scalar bound against an enum column. The one exception is a bare function-call argument position: `unnest($1)` (`FunctionSource` args) and `FunctionCallExpr`/`WindowFuncExpr` args. There is no adjacent operand to fix the type from in that position, and a genuinely polymorphic function like `unnest(anyarray)` cannot resolve an `unknown`-typed argument at all ("could not determine polymorphic type because input has type unknown") — this is specific to polymorphic functions, not a general "function arguments can't infer" rule: a non-polymorphic function called with a scalar `unknown` param (e.g. `concat($1, ...)`) resolves fine from its one candidate signature and stays uncast. `renderFunctionArgExpr` forces the array cast (`forceArrayCast`) at exactly those call sites regardless of element-type inferrability; it is a no-op for scalar (non-`many`) params. `renderAggregateExpr` is deliberately exempt from `renderFunctionArgExpr`: an aggregate's argument is always a per-row scalar expression in this codebase's operation surface, never a caller-bound whole-array parameter, so no `many`-typed `ParamRef` reaches it. + * + * `OperationExpr` (`renderOperation`) is a known gap, deliberately left unforced: `lowering.strategy` ('infix' | 'function') classifies the *authoring surface* (method-style vs operator-style on the builder), not the emitted SQL shape, so it cannot be used to decide whether `self`/`args` sit in a function-call position. `'{{self}} <=> {{arg0}}'` (pgvector) and `'{{self}} @@@ {{arg0}}'` (paradedb) are both tagged `'function'` despite being binary operators, while `'{{self}} ILIKE {{arg0}}'` is tagged `'infix'` — the opposite of what the names suggest. No operation in this codebase declares an array-typed `self` or argument today, so this is currently inert; if one ever does, `renderOperation` needs its own position information (not `lowering.strategy`) before it can safely force a cast. + * + * An array parameter whose element `nativeType` is itself outside this set (e.g. `jsonb[]`, `vector[]`) still casts in every position — the function-argument exception above only changes the *inferrable* case. + * + * ADR 205 predates array parameters and lists them as out of scope (see "Out of scope"); this comment is the only existing record of the policy above and needs folding into the ADR as an owner-approved amendment. */ const POSTGRES_INFERRABLE_NATIVE_TYPES: ReadonlySet = new Set([ // Numeric @@ -89,6 +97,7 @@ function renderTypedParam( codecDescriptorRegistry: PostgresCodecDescriptorRegistry, many?: boolean, typeParams?: JsonValue, + forceArrayCast?: boolean, ): string { if (codecId === undefined) { return `$${index}`; @@ -116,7 +125,7 @@ function renderTypedParam( if (isPgEnumParams(typeParams)) { return `$${index}::${quoteQualifiedName(nativeType)}${arraySuffix}`; } - if (!POSTGRES_INFERRABLE_NATIVE_TYPES.has(nativeType) || many) { + if (!POSTGRES_INFERRABLE_NATIVE_TYPES.has(nativeType) || (many && forceArrayCast)) { return `$${index}::${nativeType}${arraySuffix}`; } return `$${index}`; @@ -479,7 +488,7 @@ function renderSource( case 'derived-table-source': return `(${renderSelect(node.query, contract, pim)}) AS ${quoteIdentifier(node.alias)}`; case 'function-source': { - const args = node.args.map((arg) => renderExpr(arg, contract, pim)).join(', '); + const args = node.args.map((arg) => renderFunctionArgExpr(arg, contract, pim)).join(', '); const call = `${node.fn}(${args})`; const ordinality = node.ordinality ? ' WITH ORDINALITY' : ''; const alias = node.alias === undefined ? '' : ` AS ${quoteIdentifier(node.alias)}`; @@ -658,7 +667,7 @@ function renderWindowFuncExpr( pim: ParamIndexMap, ): string { const fn = expr.fn.toUpperCase(); - const args = expr.args.map((arg) => renderExpr(arg, contract, pim)).join(', '); + const args = expr.args.map((arg) => renderFunctionArgExpr(arg, contract, pim)).join(', '); const partitionClause = expr.partitionBy && expr.partitionBy.length > 0 ? `PARTITION BY ${expr.partitionBy.map((e) => renderExpr(e, contract, pim)).join(', ')}` @@ -676,7 +685,7 @@ function renderFunctionCallExpr( contract: PostgresContract, pim: ParamIndexMap, ): string { - const args = expr.args.map((arg) => renderExpr(arg, contract, pim)).join(', '); + const args = expr.args.map((arg) => renderFunctionArgExpr(arg, contract, pim)).join(', '); return `${expr.fn}(${args})`; } @@ -837,7 +846,7 @@ function renderExpr(expr: AnyExpression, contract: PostgresContract, pim: ParamI } } -function renderParamRef(ref: AnyParamRef, pim: ParamIndexMap): string { +function renderParamRef(ref: AnyParamRef, pim: ParamIndexMap, forceArrayCast?: boolean): string { const index = pim.indexMap.get(ref); if (index === undefined) { throw new InternalError('ParamRef not found in index map'); @@ -849,6 +858,7 @@ function renderParamRef(ref: AnyParamRef, pim: ParamIndexMap): string { pim.codecDescriptorRegistry, ref.codec.many, ref.codec.typeParams, + forceArrayCast, ); } if (ref.codec === undefined) { @@ -866,9 +876,21 @@ function renderParamRef(ref: AnyParamRef, pim: ParamIndexMap): string { pim.codecDescriptorRegistry, ref.codec.many, ref.codec.typeParams, + forceArrayCast, ); } +function renderFunctionArgExpr( + expr: AnyExpression, + contract: PostgresContract, + pim: ParamIndexMap, +): string { + if (expr.kind === 'param-ref' || expr.kind === 'prepared-param-ref') { + return renderParamRef(expr, pim, true); + } + return renderExpr(expr, contract, pim); +} + function renderLiteral(expr: LiteralExpr): string { if (typeof expr.value === 'string') { return `'${escapeLiteral(expr.value)}'`; diff --git a/packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts new file mode 100644 index 000000000000..fbb7a571067e --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts @@ -0,0 +1,145 @@ +/** + * Issue #30165: an array `ParamRef` always cast, even for an inferrable + * element type, so it could not resolve against a native enum-array column. + * Verified via a raw-client `::text[]` readback, not the ORM decode path — + * decoding a native enum array is issue #30164, tracked separately. + */ + +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import postgresRuntimeDriverDescriptor from '@internal/driver-postgres/runtime'; +import { instantiateExecutionStack } from '@internal/framework-components/execution'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { SqlStorage } from '@internal/sql-contract/types'; +import { InsertAst, ParamRef, TableSource } from '@internal/sql-relational-core/ast'; +import { planFromAst } from '@internal/sql-relational-core/plan'; +import { + createExecutionContext, + createSqlExecutionStack, + type Runtime, +} from '@internal/sql-runtime'; +import { createTestRuntime } from '@internal/sql-runtime/test/utils'; +import postgresRuntimeTargetDescriptor from '@internal/target-postgres/runtime'; +import { applicationDomainOf, createDevDatabase, timeouts, withClient } from '@repo/test-utils'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../../../2-sql/1-core/contract/test/test-support'; +import postgresRuntimeAdapterDescriptorFull from '../src/exports/runtime'; + +const { queryOperations: _stripOps, ...postgresRuntimeAdapterDescriptor } = + postgresRuntimeAdapterDescriptorFull; + +function buildContract(): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('enum-array-inferrable-write'), + storage: new SqlStorage({ + storageHash: coreHash('enum-array-inferrable-write'), + namespaces: { + [UNBOUND_NAMESPACE_ID]: createTestSqlNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + probe: { + columns: { + id: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + moods: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: true, + many: true, + }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +const TABLE = TableSource.named('probe'); + +function buildInsertAst(id: string, moods: string[]): InsertAst { + return InsertAst.into(TABLE).withRows([ + { + id: ParamRef.of(id, { codec: { codecId: 'pg/text@1' } }), + moods: ParamRef.of(moods, { codec: { codecId: 'pg/text@1', many: true } }), + }, + ]); +} + +describe('array write against a native-enum-array column resolved via the untyped-parameter path (issue #30165)', { + concurrent: false, +}, () => { + let database: Awaited> | undefined; + let runtime: Runtime | undefined; + + beforeAll(async () => { + database = await createDevDatabase(); + + await withClient(database.connectionString, async (client) => { + await client.query(`CREATE TYPE "Mood" AS ENUM ('URGENT', 'NORMAL', 'LOW')`); + await client.query(` + CREATE TABLE probe ( + id text PRIMARY KEY, + moods "Mood"[] + ) + `); + }); + + const contract = buildContract(); + const stack = createSqlExecutionStack({ + target: postgresRuntimeTargetDescriptor, + adapter: postgresRuntimeAdapterDescriptor, + extensions: [], + }); + const context = createExecutionContext({ contract, stack }); + const stackInstance = instantiateExecutionStack(stack); + + const driver = postgresRuntimeDriverDescriptor.create(); + await driver.connect({ kind: 'url', url: database.connectionString }); + + runtime = createTestRuntime({ stackInstance, context, driver, verifyMarker: false }); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (runtime) { + await runtime.close(); + runtime = undefined; + } + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it('writes text[]-declared array parameters, including an empty array, into a native "Mood"[] column', { + timeout: timeouts.spinUpPpgDev, + }, async () => { + const contract = buildContract(); + + await runtime! + .query(planFromAst(buildInsertAst('probe-1', ['URGENT', 'NORMAL']), contract)) + .toArray(); + await runtime!.query(planFromAst(buildInsertAst('probe-2', []), contract)).toArray(); + + await withClient(database!.connectionString, async (client) => { + const result = await client.query<{ id: string; moods: string[] }>( + 'SELECT id, moods::text[] AS moods FROM probe WHERE id = ANY($1) ORDER BY id', + [['probe-1', 'probe-2']], + ); + + expect(result.rows).toEqual([ + { id: 'probe-1', moods: ['URGENT', 'NORMAL'] }, + { id: 'probe-2', moods: [] }, + ]); + }); + }); +}); diff --git a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts index 176c843b7310..3978d31207d2 100644 --- a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts @@ -5,11 +5,13 @@ import type { RuntimeExtensionDescriptor } from '@internal/framework-components/ import { BinaryExpr, ColumnRef, + FunctionCallExpr, ParamRef, type ProjectionExpr, ProjectionItem, SelectAst, TableSource, + WindowFuncExpr, } from '@internal/sql-relational-core/ast'; import { codecRefForStorageColumn } from '@internal/sql-relational-core/codec-descriptor-registry'; import { @@ -79,6 +81,13 @@ const baseContract = new SqlContractSerializer().deserializeContract({ nullable: false, typeParams: { typeName: 'aal_level' }, }, + tags: { codecId: 'pg/text@1', nativeType: 'text', nullable: false, many: true }, + tagList: { + codecId: 'app/test-foo@1', + nativeType: 'foo', + nullable: false, + many: true, + }, }, uniques: [], indexes: [], @@ -169,26 +178,78 @@ describe('renderLoweredSql cast policy', () => { expect(lowered.sql).toBe('SELECT "user"."id" AS "id" FROM "user" WHERE "user"."score" = $1'); }); - it('casts scalar arrays even when their element native type is inferrable', () => { + it('emits plain $N for an array param whose element native type is inferrable, in comparison position (issue #30165)', () => { const ast = SelectAst.from(TableSource.named('user')) .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) .withWhere( BinaryExpr.eq( - ColumnRef.of('user', 'score'), - ParamRef.of([1, 2], { - name: 'scores', - codec: { codecId: 'pg/int4@1', many: true }, + ColumnRef.of('user', 'tags'), + ParamRef.of(['a', 'b'], { + name: 'tags', + codec: { codecId: 'pg/text@1', many: true }, }), ), ); const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry); + expect(lowered.sql).toBe('SELECT "user"."id" AS "id" FROM "user" WHERE "user"."tags" = $1'); + }); + + it('emits $N::[] for an array param whose element native type is outside the inferrable set, even in comparison position (issue #30165)', () => { + const registry = buildPostgresCodecDescriptorRegistry([descriptorFor('app/test-foo@1', 'foo')]); + const ast = SelectAst.from(TableSource.named('user')) + .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) + .withWhere( + BinaryExpr.eq( + ColumnRef.of('user', 'tagList'), + ParamRef.of(['a', 'b'], { + name: 'tagList', + codec: { codecId: 'app/test-foo@1', many: true }, + }), + ), + ); + + const lowered = renderLoweredSql(ast, baseContract, registry); + expect(lowered.sql).toBe( - 'SELECT "user"."id" AS "id" FROM "user" WHERE "user"."score" = $1::integer[]', + 'SELECT "user"."id" AS "id" FROM "user" WHERE "user"."tagList" = $1::foo[]', ); }); + it('forces the array cast for a many param used as a bare FunctionCallExpr argument, regardless of element-type inferrability (issue #30165)', () => { + const ast = SelectAst.from(TableSource.named('user')).withProjection([ + ProjectionItem.of( + 'result', + FunctionCallExpr.of('some_fn', [ + ParamRef.of(['a', 'b'], { name: 'tags', codec: { codecId: 'pg/text@1', many: true } }), + ]), + ), + ]); + + const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry); + + expect(lowered.sql).toBe('SELECT some_fn($1::text[]) AS "result" FROM "user"'); + }); + + it('forces the array cast for a many param used as a bare WindowFuncExpr argument, regardless of element-type inferrability (issue #30165)', () => { + const ast = SelectAst.from(TableSource.named('user')).withProjection([ + ProjectionItem.of( + 'result', + new WindowFuncExpr({ + fn: 'row_number', + args: [ + ParamRef.of(['a', 'b'], { name: 'tags', codec: { codecId: 'pg/text@1', many: true } }), + ], + }), + ), + ]); + + const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry); + + expect(lowered.sql).toBe('SELECT ROW_NUMBER($1::text[]) OVER () AS "result" FROM "user"'); + }); + it('resolves parameterized descriptors without requiring an id-keyed codec representative', () => { const registry = buildPostgresCodecDescriptorRegistry([ descriptorFor('arktype/json@1', 'jsonb'), From 9cbcb0a63a75f05e8e912ef2f8493077cd21651a Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Wed, 2 Sep 2026 08:51:32 +0000 Subject: [PATCH 2/2] fix(target-postgres): drop explanatory comments Reverts the cast-policy docblock to its original text and removes the test file header comment. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UcqoY3CKfnubdZt5YQk2Rq Signed-off-by: Steven McClankerton --- .../6-adapters/postgres/src/core/sql-renderer.ts | 8 -------- .../test/enum-array-inferrable-write.integration.test.ts | 7 ------- 2 files changed, 15 deletions(-) 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 a69f652b60c7..4587a03c90be 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 @@ -56,14 +56,6 @@ import type { PostgresContract } from './types'; * `json` / `jsonb` are intentionally excluded despite being Postgres builtins: their operator overloads make context inference unreliable in expression positions (e.g. `$1 -> 'key'` is ambiguous between the two). * * Spellings match the target descriptors' `nativeTypeFor` results, not the `udt_name` abbreviations that ADR 205 used as illustrative shorthand. The registry-based cast policy compares against these strings directly. - * - * Array (`many: true`) parameters are position-dependent (issue #30165). In comparison/assignment position (`col = $1`, `INSERT ... VALUES ($1)`) they follow this same inferrable-type rule as scalars and emit bare `$N` — Postgres derives the type from the target column, same as it does for a scalar bound against an enum column. The one exception is a bare function-call argument position: `unnest($1)` (`FunctionSource` args) and `FunctionCallExpr`/`WindowFuncExpr` args. There is no adjacent operand to fix the type from in that position, and a genuinely polymorphic function like `unnest(anyarray)` cannot resolve an `unknown`-typed argument at all ("could not determine polymorphic type because input has type unknown") — this is specific to polymorphic functions, not a general "function arguments can't infer" rule: a non-polymorphic function called with a scalar `unknown` param (e.g. `concat($1, ...)`) resolves fine from its one candidate signature and stays uncast. `renderFunctionArgExpr` forces the array cast (`forceArrayCast`) at exactly those call sites regardless of element-type inferrability; it is a no-op for scalar (non-`many`) params. `renderAggregateExpr` is deliberately exempt from `renderFunctionArgExpr`: an aggregate's argument is always a per-row scalar expression in this codebase's operation surface, never a caller-bound whole-array parameter, so no `many`-typed `ParamRef` reaches it. - * - * `OperationExpr` (`renderOperation`) is a known gap, deliberately left unforced: `lowering.strategy` ('infix' | 'function') classifies the *authoring surface* (method-style vs operator-style on the builder), not the emitted SQL shape, so it cannot be used to decide whether `self`/`args` sit in a function-call position. `'{{self}} <=> {{arg0}}'` (pgvector) and `'{{self}} @@@ {{arg0}}'` (paradedb) are both tagged `'function'` despite being binary operators, while `'{{self}} ILIKE {{arg0}}'` is tagged `'infix'` — the opposite of what the names suggest. No operation in this codebase declares an array-typed `self` or argument today, so this is currently inert; if one ever does, `renderOperation` needs its own position information (not `lowering.strategy`) before it can safely force a cast. - * - * An array parameter whose element `nativeType` is itself outside this set (e.g. `jsonb[]`, `vector[]`) still casts in every position — the function-argument exception above only changes the *inferrable* case. - * - * ADR 205 predates array parameters and lists them as out of scope (see "Out of scope"); this comment is the only existing record of the policy above and needs folding into the ADR as an owner-approved amendment. */ const POSTGRES_INFERRABLE_NATIVE_TYPES: ReadonlySet = new Set([ // Numeric diff --git a/packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts index fbb7a571067e..05d86da5295a 100644 --- a/packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts @@ -1,10 +1,3 @@ -/** - * Issue #30165: an array `ParamRef` always cast, even for an inferrable - * element type, so it could not resolve against a native enum-array column. - * Verified via a raw-client `::text[]` readback, not the ORM decode path — - * decoding a native enum array is issue #30164, tracked separately. - */ - import { type Contract, coreHash, profileHash } from '@internal/contract/types'; import postgresRuntimeDriverDescriptor from '@internal/driver-postgres/runtime'; import { instantiateExecutionStack } from '@internal/framework-components/execution';