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
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,15 @@ export function validateCodecTypeParams(descriptor: AnyCodecDescriptor, ref: Cod

/**
* Resolves a `Codec` instance: validates `ref.typeParams` via
* {@link validateCodecTypeParams} then calls `descriptor.factory(validated)(ctx)`.
*
* The descriptor's `factory` is typed against its own `P`; the registry erases
* `P` to `any`, so the factory is narrowed to `(params: unknown) => (ctx) => Codec`
* at the call boundary. The `paramsSchema` validates the input above before we
* forward it, so the narrowing is safe by construction.
* {@link validateCodecTypeParams} then calls `descriptor.factory(validated)(ctx)`
* as a method on `descriptor`, preserving `this` for factories that build
* their returned codec from the descriptor instance (e.g. `new XCodec(this)`).
*/
export function materializeCodec(
descriptor: AnyCodecDescriptor,
ref: CodecRef,
ctx: CodecInstanceContext,
): Codec {
const validated = validateCodecTypeParams(descriptor, ref);
return blindCast<
(params: unknown) => (ctx: CodecInstanceContext) => Codec,
'registry erases P to any; paramsSchema validates input before forwarding'
>(descriptor.factory)(validated)(ctx);
return descriptor.factory(validated)(ctx);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { JsonValue } from '@internal/contract/types';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { test } from 'vitest';
import {
type AnyCodecDescriptor,
type CodecCallContext,
type CodecDescriptor,
CodecDescriptorImpl,
CodecImpl,
type CodecInstanceContext,
type CodecRef,
type CodecTrait,
materializeCodec,
voidParamsSchema,
} from '../src/exports/codec';

class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], number, number> {
async encode(value: number, _ctx: CodecCallContext): Promise<number> {
return value;
}
async decode(wire: number, _ctx: CodecCallContext): Promise<number> {
return wire;
}
encodeJson(value: number): JsonValue {
return value;
}
decodeJson(json: JsonValue): number {
return json as number;
}
}

class Int4FixtureDescriptor extends CodecDescriptorImpl<void> {
override readonly codecId = 'demo/int4@1' as const;
override readonly traits: readonly CodecTrait[] = ['equality'];
override readonly targetTypes: readonly string[] = ['int4'];
override readonly paramsSchema: StandardSchemaV1<void> = voidParamsSchema;
override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec {
return () => new Int4FixtureCodec(this);
}
}

const int4FixtureDescriptor = new Int4FixtureDescriptor();

type VectorParams = { readonly length: number };
const vectorFixtureParamsSchema: StandardSchemaV1<VectorParams> = {
'~standard': {
version: 1,
vendor: 'demo',
validate: (input) => ({ value: input as VectorParams }),
},
};

class VectorFixtureCodec<N extends number> extends CodecImpl<
'demo/vector@1',
readonly ['equality'],
string,
number[]
> {
constructor(
descriptor: CodecDescriptor<VectorParams>,
public readonly dimension: N,
) {
super(descriptor);
}
async encode(value: number[], _ctx: CodecCallContext): Promise<string> {
return `[${value.join(',')}]`;
}
async decode(wire: string, _ctx: CodecCallContext): Promise<number[]> {
return wire.slice(1, -1).split(',').map(Number);
}
encodeJson(value: number[]): JsonValue {
return value;
}
decodeJson(json: JsonValue): number[] {
return json as number[];
}
}

class VectorFixtureDescriptor extends CodecDescriptorImpl<VectorParams> {
override readonly codecId = 'demo/vector@1' as const;
override readonly traits: readonly CodecTrait[] = ['equality'];
override readonly targetTypes: readonly string[] = ['vector'];
override readonly paramsSchema = vectorFixtureParamsSchema;
override factory<N extends number>(params: {
readonly length: N;
}): (ctx: CodecInstanceContext) => VectorFixtureCodec<N> {
return () => new VectorFixtureCodec<N>(this, params.length);
}
}

const vectorFixtureDescriptor = new VectorFixtureDescriptor();

const stubCtx = {} as CodecInstanceContext;

function descriptorFor(ref: CodecRef): AnyCodecDescriptor {
if (ref.codecId === int4FixtureDescriptor.codecId) return int4FixtureDescriptor;
if (ref.codecId === vectorFixtureDescriptor.codecId) return vectorFixtureDescriptor;
throw new Error(`no fixture descriptor for ${ref.codecId}`);
}

test('materializeCodec resolves a non-parameterized codec whose id reads the descriptor codecId', ({
expect,
}) => {
const ref: CodecRef = { codecId: 'demo/int4@1' };
const codec = materializeCodec(descriptorFor(ref), ref, stubCtx);
expect(codec.id).toBe('demo/int4@1');
});

test('materializeCodec resolves a parameterized codec whose id reads the descriptor codecId', ({
expect,
}) => {
const ref: CodecRef = { codecId: 'demo/vector@1', typeParams: { length: 1536 } };
const codec = materializeCodec(descriptorFor(ref), ref, stubCtx);
expect(codec.id).toBe('demo/vector@1');
});

test('materializeCodec produces a codec whose encode/decode still run through the descriptor-bound factory', async ({
expect,
}) => {
const ref: CodecRef = { codecId: 'demo/vector@1', typeParams: { length: 3 } };
const codec = materializeCodec(descriptorFor(ref), ref, stubCtx);
const wire = await codec.encode([1, 2, 3], {});
expect(wire).toBe('[1,2,3]');
expect(await codec.decode(wire, {})).toEqual([1, 2, 3]);
});
15 changes: 11 additions & 4 deletions packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from '@internal/sql-relational-core/ast';
import type { PostgresCodecDescriptorRegistry } from '@internal/target-postgres/codec-descriptor';
import { PG_ENUM_CODEC_ID } from '@internal/target-postgres/codec-ids';
import { isPgEnumParams } from '@internal/target-postgres/codecs';
import { isPgEnumParams, pgEnumDescriptor } from '@internal/target-postgres/codecs';
import {
escapeLiteral,
quoteIdentifier,
Expand Down Expand Up @@ -379,6 +379,10 @@ function renderOrderByExpr(
return renderExpr(expr, contract, pim);
}

function projectsNativeEnumArray(codec: CodecRef | undefined): boolean {
return codec?.many === true && codec.codecId === pgEnumDescriptor.codecId;
}

function renderProjection(
projection: ReadonlyArray<ProjectionItem>,
contract: PostgresContract,
Expand All @@ -390,7 +394,9 @@ function renderProjection(
if (item.expr.kind === 'literal') {
return `${renderLiteral(item.expr)} AS ${alias}`;
}
return `${renderExpr(item.expr, contract, pim)} AS ${alias}`;
const rendered = renderExpr(item.expr, contract, pim);
const cast = projectsNativeEnumArray(item.codec) ? '::text[]' : '';
return `${rendered}${cast} AS ${alias}`;
})
.join(', ');
}
Expand All @@ -403,8 +409,9 @@ function renderReturning(
return items
.map((item) => {
if (item.expr.kind === 'column-ref') {
const rendered = renderColumn(item.expr);
return item.expr.column === item.alias
const cast = projectsNativeEnumArray(item.codec) ? '::text[]' : '';
const rendered = `${renderColumn(item.expr)}${cast}`;
return item.expr.column === item.alias && cast === ''
? rendered
: `${rendered} AS ${quoteIdentifier(item.alias)}`;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
ColumnRef,
InsertAst,
ParamRef,
ProjectionItem,
SelectAst,
TableSource,
} from '@internal/sql-relational-core/ast';
import { postgresCodecDescriptorRegistry } from '@internal/target-postgres/codecs';
import { applicationDomainOf } from '@repo/test-utils';
import { describe, expect, it } from 'vitest';
import { TestSqlContractSerializer as SqlContractSerializer } from '../../../../2-sql/9-family/test/test-sql-contract-serializer';
import { renderLoweredSql } from '../src/core/sql-renderer';
import type { PostgresContract } from '../src/core/types';

const baseContract = new SqlContractSerializer().deserializeContract({
target: 'postgres',
targetFamily: 'sql',
profileHash: 'sha256:enum-array-projection-cast-test',
roots: {},
capabilities: { returning: { enabled: true } },
extensions: {},
meta: {},
storage: {
storageHash: 'sha256:enum-array-projection-cast',
namespaces: {
__unbound__: {
id: '__unbound__',
entries: {
table: {
probe: {
columns: {
id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
mood: {
codecId: 'pg/enum@1',
nativeType: 'mood',
nullable: false,
typeParams: { typeName: 'mood' },
},
moods: {
codecId: 'pg/enum@1',
nativeType: 'mood',
nullable: false,
many: true,
typeParams: { typeName: 'mood' },
},
labels: { codecId: 'pg/text@1', nativeType: 'text', nullable: false, many: true },
},
uniques: [],
indexes: [],
foreignKeys: [],
},
},
},
},
},
},
domain: applicationDomainOf({ models: {} }),
}) as PostgresContract;

function selectProjection(
alias: string,
column: string,
codec?: Parameters<typeof ProjectionItem.of>[2],
) {
return SelectAst.from(TableSource.named('probe')).withProjection([
ProjectionItem.of(alias, ColumnRef.of('probe', column), codec),
]);
}

describe('renderLoweredSql — native-enum array projection cast', () => {
it('casts a many + pg/enum@1 SELECT projection to ::text[]', () => {
const ast = selectProjection('moods', 'moods', {
codecId: 'pg/enum@1',
many: true,
typeParams: { typeName: 'mood' },
});

const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry);

expect(lowered.sql).toBe('SELECT "probe"."moods"::text[] AS "moods" FROM "probe"');
});

it('casts a many + pg/enum@1 RETURNING projection to ::text[] with an explicit alias', () => {
const ast = InsertAst.into(TableSource.named('probe'))
.withRows([{ id: ParamRef.of('1', { name: 'id', codec: { codecId: 'pg/text@1' } }) }])
.withReturning([
ProjectionItem.of('moods', ColumnRef.of('probe', 'moods'), {
codecId: 'pg/enum@1',
many: true,
typeParams: { typeName: 'mood' },
}),
]);

const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry);

expect(lowered.sql).toBe(
'INSERT INTO "probe" ("id") VALUES ($1) RETURNING "probe"."moods"::text[] AS "moods"',
);
});

it('leaves a scalar (non-many) pg/enum@1 projection uncast', () => {
const ast = selectProjection('mood', 'mood', {
codecId: 'pg/enum@1',
typeParams: { typeName: 'mood' },
});

const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry);

expect(lowered.sql).toBe('SELECT "probe"."mood" AS "mood" FROM "probe"');
});

it('leaves an ordinary many text[] projection uncast', () => {
const ast = selectProjection('labels', 'labels', { codecId: 'pg/text@1', many: true });

const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry);

expect(lowered.sql).toBe('SELECT "probe"."labels" AS "labels" FROM "probe"');
});

it('leaves an ordinary scalar text projection uncast', () => {
const ast = selectProjection('id', 'id', { codecId: 'pg/text@1' });

const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry);

expect(lowered.sql).toBe('SELECT "probe"."id" AS "id" FROM "probe"');
});

it('leaves a projection with no codec uncast', () => {
const ast = selectProjection('moods', 'moods');

const lowered = renderLoweredSql(ast, baseContract, postgresCodecDescriptorRegistry);

expect(lowered.sql).toBe('SELECT "probe"."moods" AS "moods" FROM "probe"');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
native_enum Mood {
URGENT = "URGENT"
NORMAL = "NORMAL"
LOW = "LOW"
}

model Probe {
id String @id
moods pg.enum(Mood)[]
note String
}
Loading
Loading