Skip to content
Closed
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
24 changes: 19 additions & 5 deletions packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function renderTypedParam(
codecDescriptorRegistry: PostgresCodecDescriptorRegistry,
many?: boolean,
typeParams?: JsonValue,
forceArrayCast?: boolean,
): string {
if (codecId === undefined) {
return `$${index}`;
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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)}`;
Expand Down Expand Up @@ -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(', ')}`
Expand All @@ -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})`;
}

Expand Down Expand Up @@ -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');
Expand All @@ -849,6 +850,7 @@ function renderParamRef(ref: AnyParamRef, pim: ParamIndexMap): string {
pim.codecDescriptorRegistry,
ref.codec.many,
ref.codec.typeParams,
forceArrayCast,
);
}
if (ref.codec === undefined) {
Expand All @@ -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)}'`;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SqlStorage> {
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<ReturnType<typeof createDevDatabase>> | 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: [] },
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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::<nativeType>[] 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'),
Expand Down
Loading