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..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 @@ -89,6 +89,7 @@ function renderTypedParam( codecDescriptorRegistry: PostgresCodecDescriptorRegistry, many?: boolean, typeParams?: JsonValue, + forceArrayCast?: boolean, ): string { if (codecId === undefined) { return `$${index}`; @@ -116,7 +117,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 +480,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 +659,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 +677,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 +838,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 +850,7 @@ function renderParamRef(ref: AnyParamRef, pim: ParamIndexMap): string { pim.codecDescriptorRegistry, ref.codec.many, ref.codec.typeParams, + forceArrayCast, ); } if (ref.codec === undefined) { @@ -866,9 +868,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..05d86da5295a --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/enum-array-inferrable-write.integration.test.ts @@ -0,0 +1,138 @@ +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'),