diff --git a/packages/1-framework/1-core/operations/src/index.ts b/packages/1-framework/1-core/operations/src/index.ts index d3f9eb269a..0bcf0f9fc3 100644 --- a/packages/1-framework/1-core/operations/src/index.ts +++ b/packages/1-framework/1-core/operations/src/index.ts @@ -10,8 +10,14 @@ export interface ReturnSpec { } export type SelfSpec = - | { readonly codecId: string; readonly traits?: never } - | { readonly traits: readonly string[]; readonly codecId?: never }; + | { readonly codecId: string; readonly traits?: never; readonly many?: never } + | { readonly traits: readonly string[]; readonly codecId?: never; readonly many?: never } + | { + readonly many: true; + readonly elementTraits?: readonly string[]; + readonly codecId?: never; + readonly traits?: never; + }; export interface OperationEntry { readonly self?: SelfSpec; @@ -42,10 +48,12 @@ export function createOperationRegistry< if (descriptor.self) { const hasCodecId = descriptor.self.codecId !== undefined; const hasTraits = descriptor.self.traits !== undefined && descriptor.self.traits.length > 0; - if (!hasCodecId && !hasTraits) { + const hasMany = descriptor.self.many === true; + const targetCount = Number(hasCodecId) + Number(hasTraits) + Number(hasMany); + if (targetCount === 0) { throw new Error(`Operation "${name}" self has neither codecId nor traits`); } - if (hasCodecId && hasTraits) { + if (targetCount > 1) { throw new Error(`Operation "${name}" self has both codecId and traits`); } } diff --git a/packages/1-framework/1-core/operations/test/operations-registry.test.ts b/packages/1-framework/1-core/operations/test/operations-registry.test.ts index c3ad0bcab6..fbabc37c84 100644 --- a/packages/1-framework/1-core/operations/test/operations-registry.test.ts +++ b/packages/1-framework/1-core/operations/test/operations-registry.test.ts @@ -96,6 +96,32 @@ describe('OperationRegistry', () => { ).not.toThrow(); }); + it('accepts a list (many) self', () => { + const registry = createOperationRegistry(); + + expect(() => registry.register('has', descriptor({ self: { many: true } }))).not.toThrow(); + }); + + it('accepts a list (many) self with elementTraits', () => { + const registry = createOperationRegistry(); + + expect(() => + registry.register('has', descriptor({ self: { many: true, elementTraits: ['equality'] } })), + ).not.toThrow(); + }); + + it('throws when self combines many with codecId', () => { + const registry = createOperationRegistry(); + + expect(() => + registry.register('bad', { + // @ts-expect-error — SelfSpec disallows many alongside codecId + self: { many: true, codecId: 'pg/text@1' }, + impl: noopImpl, + }), + ).toThrow('Operation "bad" self has both codecId and traits'); + }); + it('accepts self-less operation', () => { const registry = createOperationRegistry(); diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index 561d86baf1..89fd6775e3 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -587,8 +587,9 @@ function generateTableLiteralType(table: StorageTable): string { ? `; readonly typeParams: ${serializeTypeParamsLiteral(col.typeParams)}` : ''; const typeRefSpec = col.typeRef ? `; readonly typeRef: ${serializeValue(col.typeRef)}` : ''; + const manySpec = col.many === true ? '; readonly many: true' : ''; columns.push( - `readonly ${colName}: { readonly nativeType: ${nativeType}; readonly codecId: ${codecId}; readonly nullable: ${nullable}${defaultSpec}${typeParamsSpec}${typeRefSpec} }`, + `readonly ${colName}: { readonly nativeType: ${nativeType}; readonly codecId: ${codecId}; readonly nullable: ${nullable}${manySpec}${defaultSpec}${typeParamsSpec}${typeRefSpec} }`, ); } diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts index 453afb18f5..e200f7be7c 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.storage-column-types.test.ts @@ -919,4 +919,37 @@ describe('StorageColumnTypes', () => { "readonly labels: ReadonlyArray | null", ); }); + + it('projects many: true onto the storage table column literal for a native many[] column', () => { + const contract = createContract({ + models: { + Post: { + storage: { table: 'post', fields: { tags: { column: 'tags' } } }, + fields: { + tags: { nullable: false, many: true, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: {}, + }, + }, + storage: { + tables: { + post: { + columns: { + tags: { nativeType: 'text', codecId: 'pg/text@1', nullable: false, many: true }, + }, + primaryKey: { columns: ['tags'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }); + + const dts = generateContractDts(contract, sqlEmission, [], testHashes); + + expect(dts).toContain( + "readonly tags: { readonly nativeType: 'text'; readonly codecId: 'pg/text@1'; readonly nullable: false; readonly many: true }", + ); + }); }); diff --git a/packages/2-sql/4-lanes/relational-core/src/expression.ts b/packages/2-sql/4-lanes/relational-core/src/expression.ts index 38aa12d869..4c7f8c0ef2 100644 --- a/packages/2-sql/4-lanes/relational-core/src/expression.ts +++ b/packages/2-sql/4-lanes/relational-core/src/expression.ts @@ -33,7 +33,7 @@ export type Expression = QueryOperationReturn & { buildAst(): AstExpression; }; -type CodecIdsWithTrait< +export type CodecIdsWithTrait< CT extends Record, RequiredTraits extends readonly string[], > = { diff --git a/packages/2-sql/4-lanes/sql-builder/src/expression.ts b/packages/2-sql/4-lanes/sql-builder/src/expression.ts index 0070349c28..3c7fcb76ba 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/expression.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/expression.ts @@ -1,14 +1,38 @@ import type { QueryOperationTypesBase } from '@prisma-next/sql-contract/types'; import type { CodecExpression, + CodecIdsWithTrait, + CodecValue, Expression, RawSqlTag, + ScalarListExpression, TraitExpression, } from '@prisma-next/sql-relational-core/expression'; import type { Expand, QueryContext, Scope, ScopeField, ScopeTable, Subquery } from './scope'; export type { CodecExpression, Expression, RawSqlTag, TraitExpression }; +/** + * List-operand call signature for a comparison builtin. Applies when the + * receiver is a whole-list expression (`ScalarListExpression`, `many: true`) + * whose element codec carries every trait in `RequiredTraits` — `['equality']` + * for `eq`/`ne`, `['order']` for `gt`/`gte`/`lt`/`lte`. The value side accepts a + * matching list expression or a literal `readonly ElementValue[]`, and the whole + * comparison lowers to the same boolean expression the scalar path returns. + * + * Kept disjoint from the scalar signature by the `many` discriminant: a + * `ScalarListExpression` (`many: true`) never satisfies the scalar + * `CodecExpression` slot (`many?: never`) and vice-versa, so scalar-call + * inference is untouched. + */ +type ListComparison< + CT extends Record, + RequiredTraits extends readonly string[], +> = >( + a: ScalarListExpression, + b: ScalarListExpression | readonly CodecValue[], +) => Expression; + export type BooleanCodecType = { codecId: 'pg/bool@1'; nullable: boolean }; export type WithField = Expand< @@ -61,30 +85,36 @@ type DeriveExtFunctions = { }; export type BuiltinFunctions> = { - eq: ( - a: CodecExpression | null, - b: CodecExpression | null, - ) => Expression; - ne: ( - a: CodecExpression | null, - b: CodecExpression | null, - ) => Expression; - gt: ( - a: CodecExpression, - b: CodecExpression, - ) => Expression; - gte: ( - a: CodecExpression, - b: CodecExpression, - ) => Expression; - lt: ( - a: CodecExpression, - b: CodecExpression, - ) => Expression; - lte: ( - a: CodecExpression, - b: CodecExpression, - ) => Expression; + eq: ListComparison & + (( + a: CodecExpression | null, + b: CodecExpression | null, + ) => Expression); + ne: ListComparison & + (( + a: CodecExpression | null, + b: CodecExpression | null, + ) => Expression); + gt: ListComparison & + (( + a: CodecExpression, + b: CodecExpression, + ) => Expression); + gte: ListComparison & + (( + a: CodecExpression, + b: CodecExpression, + ) => Expression); + lt: ListComparison & + (( + a: CodecExpression, + b: CodecExpression, + ) => Expression); + lte: ListComparison & + (( + a: CodecExpression, + b: CodecExpression, + ) => Expression); and: (...ands: CodecExpression<'pg/bool@1', boolean, CT>[]) => Expression; or: (...ors: CodecExpression<'pg/bool@1', boolean, CT>[]) => Expression; diff --git a/packages/2-sql/4-lanes/sql-builder/test/types/scalar-list-ops.types.test-d.ts b/packages/2-sql/4-lanes/sql-builder/test/types/scalar-list-ops.types.test-d.ts index 260c26ca99..b455910708 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/types/scalar-list-ops.types.test-d.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/types/scalar-list-ops.types.test-d.ts @@ -18,13 +18,15 @@ import type { ScalarListExpression, } from '@prisma-next/sql-relational-core/expression'; import { expectTypeOf, test } from 'vitest'; -import type { FieldProxy, Functions } from '../../src/expression'; -import type { QueryContext, Scope } from '../../src/scope'; +import type { BooleanCodecType, FieldProxy, Functions } from '../../src/expression'; +import type { Scope } from '../../src/scope'; + +type CmpExpr = Expression; type CT = { - 'pg/int4@1': { input: number; output: number; traits: readonly ['equality', 'order'] }; - 'pg/text@1': { input: string; output: string; traits: readonly ['equality', 'textual'] }; - 'pg/bool@1': { input: boolean; output: boolean; traits: readonly ['equality'] }; + 'pg/int4@1': { input: number; output: number; traits: 'equality' | 'order' }; + 'pg/text@1': { input: string; output: string; traits: 'equality' | 'order' | 'textual' }; + 'pg/bool@1': { input: boolean; output: boolean; traits: 'equality' }; }; type BoolExpr = Expression<{ codecId: 'pg/bool@1'; nullable: false }>; @@ -40,17 +42,24 @@ type QueryOps = { }; }; -// A scope shaped like a `Post` table: scalar `name`, list `tags String[]`. +// A scope shaped like a `Post` table: scalar `name`, list `tags String[]`, +// list `flags Boolean[]` (element codec lacks the `order` trait). type PostColumns = { name: { codecId: 'pg/text@1'; nullable: false }; + id: { codecId: 'pg/int4@1'; nullable: false }; tags: { codecId: 'pg/text@1'; nullable: false; many: true }; + flags: { codecId: 'pg/bool@1'; nullable: false; many: true }; }; type PostScope = Scope & { topLevel: PostColumns; namespaces: { Post: PostColumns }; }; -type QC = QueryContext & { +// Structurally a `QueryContext`, but `codecTypes` is the concrete `CT` (no +// `CodecTypesBase` index-signature intersection) so trait resolution +// (`CodecIdsWithTrait`) sees the exact codec ids — matching how a real emitted +// contract threads its concrete `CodecTypes`. +type QC = { codecTypes: CT; capabilities: Record>; queryOperationTypes: QueryOps; @@ -79,3 +88,40 @@ test('the list op rejects a scalar receiver and a wrong-typed element', () => { // @ts-expect-error -- element must be text, not a number fns.has(f.tags, 5); }); + +test('equality builtins accept whole-list operands (literal array or list expression)', () => { + expectTypeOf(fns.eq(f.tags, ['a', 'b'])).toEqualTypeOf(); + expectTypeOf(fns.eq(f.tags, f.tags)).toEqualTypeOf(); + expectTypeOf(fns.ne(f.tags, ['a'])).toEqualTypeOf(); + // a Boolean[] list is comparable by equality (bool carries the `equality` trait) + fns.eq(f.flags, [true]); +}); + +test('ordering builtins accept whole-list operands over an orderable element', () => { + expectTypeOf(fns.gt(f.tags, ['a'])).toEqualTypeOf(); + expectTypeOf(fns.gte(f.tags, f.tags)).toEqualTypeOf(); + expectTypeOf(fns.lt(f.tags, ['z'])).toEqualTypeOf(); + expectTypeOf(fns.lte(f.tags, ['z'])).toEqualTypeOf(); +}); + +test('comparison builtins reject a wrong-typed list element', () => { + // @ts-expect-error -- tags is a text list; the array elements must be strings + fns.eq(f.tags, [5]); + // @ts-expect-error -- tags is a text list; the array elements must be strings + fns.gt(f.tags, [5]); +}); + +test('ordering builtins reject a list whose element lacks the `order` trait', () => { + // @ts-expect-error -- flags is a Boolean[]; bool lacks the `order` trait + fns.gt(f.flags, [true]); + // @ts-expect-error -- flags is a Boolean[]; bool lacks the `order` trait + fns.lt(f.flags, f.flags); +}); + +test('scalar comparison calls are unchanged by the list overloads', () => { + expectTypeOf(fns.eq(f.id, 1)).toEqualTypeOf(); + expectTypeOf(fns.gt(f.id, 1)).toEqualTypeOf(); + expectTypeOf(fns.eq(f.name, 'x')).toEqualTypeOf(); + // @ts-expect-error -- id is an int scalar; a string is not a valid operand + fns.eq(f.id, 'nope'); +}); diff --git a/packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts b/packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts index 74891f21e8..4ba1a5af0b 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts @@ -1,8 +1,13 @@ import type { CodecControlHooks, ExpandNativeTypeInput } from '@prisma-next/family-sql/control'; +import type { AnyExpression } from '@prisma-next/sql-relational-core/ast'; +import { LiteralExpr } from '@prisma-next/sql-relational-core/ast'; import { buildOperation, type CodecExpression, + type CodecValue, + codecOf, type Expression, + type ScalarListExpression, type TraitExpression, toExpr, } from '@prisma-next/sql-relational-core/expression'; @@ -38,6 +43,8 @@ import { SQL_VARCHAR_CODEC_ID, } from '@prisma-next/target-postgres/codec-ids'; import { postgresCodecRegistry } from '@prisma-next/target-postgres/codecs'; +import { blindCast } from '@prisma-next/utils/casts'; +import { ifDefined } from '@prisma-next/utils/defined'; import type { QueryOperationTypes } from '../types/operation-types'; // ============================================================================ Helper functions for reducing boilerplate ============================================================================ @@ -133,6 +140,24 @@ const identityHooks: CodecControlHooks = { expandNativeType: ({ nativeType }) => type CodecTypesBase = Record; +/** + * Lower a whole-array operand for the array filter ops. A raw JS array renders + * as a `ARRAY[...]` literal; a list expression (another list column) is lowered + * through its own AST. + */ +function arrayOperandToExpr(operand: unknown): AnyExpression { + return Array.isArray(operand) ? LiteralExpr.of(operand) : toExpr(operand); +} + +/** Element {@link CodecRef} for a list receiver, preserving type params when present. */ +function elementCodecOf(self: unknown) { + const listCodec = codecOf(self); + if (listCodec === undefined) return undefined; + return listCodec.typeParams === undefined + ? { codecId: listCodec.codecId } + : { codecId: listCodec.codecId, typeParams: listCodec.typeParams }; +} + export function postgresQueryOperations(): QueryOperationTypes { return { ilike: { @@ -149,6 +174,172 @@ export function postgresQueryOperations(): QueryOpera }); }, }, + has: { + self: { many: true, elementTraits: ['equality'] }, + impl: ( + self: ScalarListExpression, + elem: CodecExpression, + ): Expression<{ codecId: 'pg/bool@1'; nullable: false }> => { + const listCodec = codecOf(self); + const elementCodec = + listCodec === undefined + ? undefined + : listCodec.typeParams === undefined + ? { codecId: listCodec.codecId } + : { codecId: listCodec.codecId, typeParams: listCodec.typeParams }; + return buildOperation({ + method: 'has', + args: [toExpr(self), toExpr(elem, elementCodec)], + returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'infix', + template: '{{arg0}} = ANY({{self}})', + }, + }); + }, + }, + arrayContains: { + self: { many: true, elementTraits: ['equality'] }, + impl: ( + self: ScalarListExpression, + other: readonly CodecValue[] | ScalarListExpression, + ): Expression<{ codecId: 'pg/bool@1'; nullable: false }> => { + return buildOperation({ + method: 'arrayContains', + args: [toExpr(self), arrayOperandToExpr(other)], + returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, + lowering: { targetFamily: 'sql', strategy: 'infix', template: '{{self}} @> {{arg0}}' }, + }); + }, + }, + containedBy: { + self: { many: true, elementTraits: ['equality'] }, + impl: ( + self: ScalarListExpression, + other: readonly CodecValue[] | ScalarListExpression, + ): Expression<{ codecId: 'pg/bool@1'; nullable: false }> => { + return buildOperation({ + method: 'containedBy', + args: [toExpr(self), arrayOperandToExpr(other)], + returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, + lowering: { targetFamily: 'sql', strategy: 'infix', template: '{{self}} <@ {{arg0}}' }, + }); + }, + }, + overlaps: { + self: { many: true, elementTraits: ['equality'] }, + impl: ( + self: ScalarListExpression, + other: readonly CodecValue[] | ScalarListExpression, + ): Expression<{ codecId: 'pg/bool@1'; nullable: false }> => { + return buildOperation({ + method: 'overlaps', + args: [toExpr(self), arrayOperandToExpr(other)], + returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, + lowering: { targetFamily: 'sql', strategy: 'infix', template: '{{self}} && {{arg0}}' }, + }); + }, + }, + length: { + self: { many: true }, + impl: ( + self: ScalarListExpression, + ): Expression<{ codecId: 'pg/int4@1'; nullable: false }> => { + return buildOperation({ + method: 'length', + args: [toExpr(self)], + returns: { codecId: PG_INT4_CODEC_ID, nullable: false }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: 'cardinality({{self}})', + }, + }); + }, + }, + index: { + self: { many: true }, + impl: ( + self: ScalarListExpression, + i: CodecExpression<'pg/int4@1', false, CT>, + ): Expression<{ codecId: CodecId; nullable: true }> => { + const listCodec = codecOf(self); + const elementCodec = + listCodec === undefined + ? undefined + : listCodec.typeParams === undefined + ? { codecId: listCodec.codecId } + : { codecId: listCodec.codecId, typeParams: listCodec.typeParams }; + return buildOperation<{ codecId: CodecId; nullable: true }>({ + method: 'index', + args: [toExpr(self), toExpr(i, { codecId: PG_INT4_CODEC_ID })], + returns: { + codecId: blindCast< + CodecId, + "the element codecId resolved from the list receiver's own codec is, by construction, this op's declared element CodecId; the runtime string can't be tied back to the generic" + >(listCodec?.codecId), + nullable: true, + ...ifDefined('codec', elementCodec), + }, + lowering: { targetFamily: 'sql', strategy: 'infix', template: '{{self}}[{{arg0}}]' }, + }); + }, + }, + arrayAppend: { + self: { many: true, elementTraits: ['equality'] }, + impl: ( + self: ScalarListExpression, + elem: CodecExpression, + ): Expression<{ codecId: CodecId; nullable: false; many: true }> => { + const elementCodec = elementCodecOf(self); + return buildOperation<{ codecId: CodecId; nullable: false; many: true }>({ + method: 'arrayAppend', + args: [toExpr(self), toExpr(elem, elementCodec)], + returns: { + codecId: blindCast< + CodecId, + "the element codecId resolved from the list receiver's own codec is, by construction, this op's declared element CodecId; the runtime string can't be tied back to the generic" + >(codecOf(self)?.codecId), + nullable: false, + many: true, + ...ifDefined('codec', elementCodec), + }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: 'array_append({{self}}, {{arg0}})', + }, + }); + }, + }, + arrayRemove: { + self: { many: true, elementTraits: ['equality'] }, + impl: ( + self: ScalarListExpression, + elem: CodecExpression, + ): Expression<{ codecId: CodecId; nullable: false; many: true }> => { + const elementCodec = elementCodecOf(self); + return buildOperation<{ codecId: CodecId; nullable: false; many: true }>({ + method: 'arrayRemove', + args: [toExpr(self), toExpr(elem, elementCodec)], + returns: { + codecId: blindCast< + CodecId, + "the element codecId resolved from the list receiver's own codec is, by construction, this op's declared element CodecId; the runtime string can't be tied back to the generic" + >(codecOf(self)?.codecId), + nullable: false, + many: true, + ...ifDefined('codec', elementCodec), + }, + lowering: { + targetFamily: 'sql', + strategy: 'function', + template: 'array_remove({{self}}, {{arg0}})', + }, + }); + }, + }, }; } diff --git a/packages/3-targets/6-adapters/postgres/src/types/operation-types.ts b/packages/3-targets/6-adapters/postgres/src/types/operation-types.ts index 02eefd4776..0b9dd7d5e4 100644 --- a/packages/3-targets/6-adapters/postgres/src/types/operation-types.ts +++ b/packages/3-targets/6-adapters/postgres/src/types/operation-types.ts @@ -1,7 +1,9 @@ import type { SqlQueryOperationTypes } from '@prisma-next/sql-contract/types'; import type { CodecExpression, + CodecValue, Expression, + ScalarListExpression, TraitExpression, } from '@prisma-next/sql-relational-core/expression'; @@ -17,5 +19,60 @@ export type QueryOperationTypes = SqlQueryOperationTy pattern: CodecExpression<'pg/text@1', false, CT>, ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; }; + readonly has: { + readonly self: { readonly many: true; readonly elementTraits: readonly ['equality'] }; + readonly impl: ( + self: ScalarListExpression, + elem: CodecExpression, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly arrayContains: { + readonly self: { readonly many: true; readonly elementTraits: readonly ['equality'] }; + readonly impl: ( + self: ScalarListExpression, + other: readonly CodecValue[] | ScalarListExpression, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly containedBy: { + readonly self: { readonly many: true; readonly elementTraits: readonly ['equality'] }; + readonly impl: ( + self: ScalarListExpression, + other: readonly CodecValue[] | ScalarListExpression, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly overlaps: { + readonly self: { readonly many: true; readonly elementTraits: readonly ['equality'] }; + readonly impl: ( + self: ScalarListExpression, + other: readonly CodecValue[] | ScalarListExpression, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + }; + readonly length: { + readonly self: { readonly many: true }; + readonly impl: ( + self: ScalarListExpression, + ) => Expression<{ codecId: 'pg/int4@1'; nullable: false }>; + }; + readonly index: { + readonly self: { readonly many: true }; + readonly impl: ( + self: ScalarListExpression, + i: CodecExpression<'pg/int4@1', false, CT>, + ) => Expression<{ codecId: CodecId; nullable: true }>; + }; + readonly arrayAppend: { + readonly self: { readonly many: true; readonly elementTraits: readonly ['equality'] }; + readonly impl: ( + self: ScalarListExpression, + elem: CodecExpression, + ) => Expression<{ codecId: CodecId; nullable: false; many: true }>; + }; + readonly arrayRemove: { + readonly self: { readonly many: true; readonly elementTraits: readonly ['equality'] }; + readonly impl: ( + self: ScalarListExpression, + elem: CodecExpression, + ) => Expression<{ codecId: CodecId; nullable: false; many: true }>; + }; } >; diff --git a/test/integration/test/scalar-lists/orm-list-read.integration.test.ts b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts index 24da8242c5..5f5ce20ac8 100644 --- a/test/integration/test/scalar-lists/orm-list-read.integration.test.ts +++ b/test/integration/test/scalar-lists/orm-list-read.integration.test.ts @@ -6,6 +6,7 @@ * accessors fully typed: `db.public.Item` is a real `Item` collection rather * than an index signature. */ +import { postgresRawCodecInferer } from '@prisma-next/adapter-postgres/adapter'; import postgresAdapter from '@prisma-next/adapter-postgres/control'; import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime'; import type { Contract as FrameworkContract } from '@prisma-next/contract/types'; @@ -13,9 +14,14 @@ import postgresControlDriver from '@prisma-next/driver-postgres/control'; import sql, { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; import { APP_SPACE_ID, createControlStack } from '@prisma-next/framework-components/control'; import { buildSynthMigrationEdge } from '@prisma-next/migration-tools/aggregate'; +import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { orm } from '@prisma-next/sql-orm-client'; -import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime'; +import { + createExecutionContext, + createSqlExecutionStack, + type SqlMiddleware, +} from '@prisma-next/sql-runtime'; import postgres from '@prisma-next/target-postgres/control'; import postgresRuntimeTarget, { PostgresContractSerializer, @@ -139,4 +145,268 @@ describe.sequential('ORM scalar-list round-trip', () => { }, timeouts.spinUpPpgDev, ); + + it( + 'filters rows through the native `has` membership op, lowering to `= ANY(...)`', + async () => { + if (!database) throw new Error('database not initialised'); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString); + + await withClient(database.connectionString, async (client) => { + const capturedSql: string[] = []; + const captureMiddleware: SqlMiddleware = { + name: 'capture-sql', + beforeExecute(plan) { + capturedSql.push(plan.sql); + }, + }; + const runtime = await createTestRuntimeFromClient( + contract as FrameworkContract, + client, + { verifyMarker: false, middleware: [captureMiddleware] }, + ); + + const context = createExecutionContext({ + contract, + stack: createSqlExecutionStack({ + target: postgresRuntimeTarget, + adapter: postgresRuntimeAdapter, + extensionPacks: [], + }), + }); + + const db = orm({ runtime, context }); + await db.public.Item.create({ id: 1, tags: ['react', 'vue'], scores: [1] }); + await db.public.Item.create({ id: 2, tags: ['vue', 'svelte'], scores: [2] }); + await db.public.Item.create({ id: 3, tags: ['svelte'], scores: [3] }); + + const builder = sqlBuilder({ context, rawCodecInferer: postgresRawCodecInferer }); + const rows = await runtime.execute( + builder.public.item + .select('id') + .where((f, fns) => fns.has(f.tags, 'vue')) + .orderBy((f) => f.id) + .build(), + ); + + expect(capturedSql.some((s) => /= ANY\(/.test(s))).toBe(true); + expect(rows).toEqual([{ id: 1 }, { id: 2 }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'filters rows through native array ops (arrayContains @>, containedBy <@, overlaps &&)', + async () => { + if (!database) throw new Error('database not initialised'); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString); + + await withClient(database.connectionString, async (client) => { + const capturedSql: string[] = []; + const captureMiddleware: SqlMiddleware = { + name: 'capture-sql', + beforeExecute(plan) { + capturedSql.push(plan.sql); + }, + }; + const runtime = await createTestRuntimeFromClient( + contract as FrameworkContract, + client, + { verifyMarker: false, middleware: [captureMiddleware] }, + ); + + const context = createExecutionContext({ + contract, + stack: createSqlExecutionStack({ + target: postgresRuntimeTarget, + adapter: postgresRuntimeAdapter, + extensionPacks: [], + }), + }); + + const db = orm({ runtime, context }); + await db.public.Item.create({ id: 1, tags: ['react', 'vue'], scores: [1] }); + await db.public.Item.create({ id: 2, tags: ['vue', 'svelte'], scores: [2] }); + await db.public.Item.create({ id: 3, tags: ['svelte'], scores: [3] }); + + const builder = sqlBuilder({ context, rawCodecInferer: postgresRawCodecInferer }); + + const within = await runtime.execute( + builder.public.item + .select('id') + .where((f, fns) => fns.containedBy(f.tags, ['vue', 'svelte'])) + .orderBy((f) => f.id) + .build(), + ); + expect(capturedSql.some((s) => s.includes('<@'))).toBe(true); + expect(capturedSql.some((s) => s.includes('ARRAY['))).toBe(true); + expect(within).toEqual([{ id: 2 }, { id: 3 }]); + + const overlapping = await runtime.execute( + builder.public.item + .select('id') + .where((f, fns) => fns.overlaps(f.tags, ['react'])) + .orderBy((f) => f.id) + .build(), + ); + expect(capturedSql.some((s) => s.includes('&&'))).toBe(true); + expect(overlapping).toEqual([{ id: 1 }]); + + const superset = await runtime.execute( + builder.public.item + .select('id') + .where((f, fns) => fns.arrayContains(f.tags, ['svelte'])) + .orderBy((f) => f.id) + .build(), + ); + expect(capturedSql.some((s) => s.includes('@>'))).toBe(true); + expect(capturedSql.some((s) => s.includes('ARRAY['))).toBe(true); + expect(superset).toEqual([{ id: 2 }, { id: 3 }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'compares whole lists through the eq/gt comparison builtins (FR16)', + async () => { + if (!database) throw new Error('database not initialised'); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString); + + await withClient(database.connectionString, async (client) => { + const capturedSql: string[] = []; + const captureMiddleware: SqlMiddleware = { + name: 'capture-sql', + beforeExecute(plan) { + capturedSql.push(plan.sql); + }, + }; + const runtime = await createTestRuntimeFromClient( + contract as FrameworkContract, + client, + { verifyMarker: false, middleware: [captureMiddleware] }, + ); + + const context = createExecutionContext({ + contract, + stack: createSqlExecutionStack({ + target: postgresRuntimeTarget, + adapter: postgresRuntimeAdapter, + extensionPacks: [], + }), + }); + + const db = orm({ runtime, context }); + await db.public.Item.create({ id: 1, tags: ['react', 'vue'], scores: [1] }); + await db.public.Item.create({ id: 2, tags: ['svelte'], scores: [2] }); + await db.public.Item.create({ id: 3, tags: ['react', 'vue'], scores: [3] }); + + const builder = sqlBuilder({ context, rawCodecInferer: postgresRawCodecInferer }); + + const equal = await runtime.execute( + builder.public.item + .select('id') + .where((f, fns) => fns.eq(f.tags, ['react', 'vue'])) + .orderBy((f) => f.id) + .build(), + ); + expect(capturedSql.some((s) => /"tags" = \$\d+/.test(s))).toBe(true); + expect(equal).toEqual([{ id: 1 }, { id: 3 }]); + + const greater = await runtime.execute( + builder.public.item + .select('id') + .where((f, fns) => fns.gt(f.tags, ['react'])) + .orderBy((f) => f.id) + .build(), + ); + expect(capturedSql.some((s) => /"tags" > \$\d+/.test(s))).toBe(true); + // ['react','vue'] and ['svelte'] both sort lexicographically after ['react'] + expect(greater).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'computes list length (cardinality) and 1-based nullable element access', + async () => { + if (!database) throw new Error('database not initialised'); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString); + + await withClient(database.connectionString, async (client) => { + const capturedSql: string[] = []; + const captureMiddleware: SqlMiddleware = { + name: 'capture-sql', + beforeExecute(plan) { + capturedSql.push(plan.sql); + }, + }; + const runtime = await createTestRuntimeFromClient( + contract as FrameworkContract, + client, + { verifyMarker: false, middleware: [captureMiddleware] }, + ); + + const context = createExecutionContext({ + contract, + stack: createSqlExecutionStack({ + target: postgresRuntimeTarget, + adapter: postgresRuntimeAdapter, + extensionPacks: [], + }), + }); + + const db = orm({ runtime, context }); + await db.public.Item.create({ id: 1, tags: ['react', 'vue'], scores: [10, 20] }); + await db.public.Item.create({ id: 2, tags: ['svelte'], scores: [30] }); + + const builder = sqlBuilder({ context, rawCodecInferer: postgresRawCodecInferer }); + const rows = await runtime.execute( + builder.public.item + .select((f, fns) => ({ + id: f.id, + len: fns.length(f.tags), + firstTag: fns.index(f.tags, 1), + secondScore: fns.index(f.scores, 2), + })) + .orderBy((f) => f.id) + .build(), + ); + + expect(capturedSql.some((s) => s.includes('cardinality('))).toBe(true); + expect(capturedSql.some((s) => /\[\$\d+/.test(s))).toBe(true); + expect(rows).toEqual([ + { id: 1, len: 2, firstTag: 'react', secondScore: 20 }, + { id: 2, len: 1, firstTag: 'svelte', secondScore: null }, + ]); + }); + }, + timeouts.spinUpPpgDev, + ); }); diff --git a/test/integration/test/scalar-lists/scalar-list-mutations.integration.test.ts b/test/integration/test/scalar-lists/scalar-list-mutations.integration.test.ts new file mode 100644 index 0000000000..c2f340ce22 --- /dev/null +++ b/test/integration/test/scalar-lists/scalar-list-mutations.integration.test.ts @@ -0,0 +1,220 @@ +/** + * End-to-end proof of the native list update mutators (`arrayAppend`, + * `arrayRemove`, and whole-array replacement) on real Postgres. + * + * An update that appends to and removes from a `tags String[]` column executes + * the corresponding Postgres array mutation (`array_append` / `array_remove`) + * and the subsequent read returns the mutated list decoded element-wise (AC7). + */ +import { postgresRawCodecInferer } from '@prisma-next/adapter-postgres/adapter'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; +import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime'; +import type { Contract as FrameworkContract } from '@prisma-next/contract/types'; +import postgresControlDriver from '@prisma-next/driver-postgres/control'; +import sql, { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; +import { APP_SPACE_ID, createControlStack } from '@prisma-next/framework-components/control'; +import { buildSynthMigrationEdge } from '@prisma-next/migration-tools/aggregate'; +import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { + createExecutionContext, + createSqlExecutionStack, + type SqlMiddleware, +} from '@prisma-next/sql-runtime'; +import postgres from '@prisma-next/target-postgres/control'; +import postgresRuntimeTarget, { + PostgresContractSerializer, +} from '@prisma-next/target-postgres/runtime'; +import { createDevDatabase, type DevDatabase, timeouts, withClient } from '@prisma-next/test-utils'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Contract } from '../sql-orm-client/fixtures/scalar-lists/generated/contract'; +import contractJson from '../sql-orm-client/fixtures/scalar-lists/generated/contract.json' with { + type: 'json', +}; +import { createTestRuntimeFromClient } from '../utils'; +import { postgresFrameworkComponents } from './psl-list-authoring'; + +const controlStack = createControlStack({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresControlDriver, + extensionPacks: [], +}); +const familyInstance = sql.create(controlStack); + +const contract = new PostgresContractSerializer().deserializeContract(contractJson) as Contract; + +async function migrateContract(connectionString: string): Promise { + const driver = await postgresControlDriver.create(connectionString); + try { + const schema = await familyInstance.introspect({ driver }); + const planner = postgres.createPlanner(postgresAdapter.create(controlStack)); + const planResult = planner.plan({ + contract: contract as FrameworkContract, + schema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents: postgresFrameworkComponents, + spaceId: APP_SPACE_ID, + }); + if (planResult.kind !== 'success') { + throw new Error(`planner failed: ${JSON.stringify(planResult)}`); + } + + const runner = postgres.createRunner(familyInstance); + const runResult = await runner.execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan: planResult.plan, + migrationEdges: [ + buildSynthMigrationEdge({ + currentMarkerStorageHash: planResult.plan.origin?.storageHash, + destinationStorageHash: planResult.plan.destination.storageHash, + operationCount: planResult.plan.operations.length, + }), + ], + driver, + destinationContract: contract as FrameworkContract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents: postgresFrameworkComponents, + }, + ], + }); + if (!runResult.ok) { + throw new Error(`runner failed: ${JSON.stringify(runResult.failure)}`); + } + } finally { + await driver.close(); + } +} + +describe.sequential('ORM scalar-list update mutators', () => { + let database: DevDatabase | undefined; + + beforeAll(async () => { + database = await createDevDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'appends to and removes from a list column via array_append/array_remove, reading back the mutated list', + async () => { + if (!database) throw new Error('database not initialised'); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString); + + await withClient(database.connectionString, async (client) => { + const capturedSql: string[] = []; + const captureMiddleware: SqlMiddleware = { + name: 'capture-sql', + beforeExecute(plan) { + capturedSql.push(plan.sql); + }, + }; + const runtime = await createTestRuntimeFromClient( + contract as FrameworkContract, + client, + { verifyMarker: false, middleware: [captureMiddleware] }, + ); + + const context = createExecutionContext({ + contract, + stack: createSqlExecutionStack({ + target: postgresRuntimeTarget, + adapter: postgresRuntimeAdapter, + extensionPacks: [], + }), + }); + + const builder = sqlBuilder({ context, rawCodecInferer: postgresRawCodecInferer }); + + await runtime.execute( + builder.public.item.insert([{ id: 1, tags: ['a', 'b'], scores: [1, 2] }]).build(), + ); + + await runtime.execute( + builder.public.item + .update((f, fns) => ({ tags: fns.arrayRemove(fns.arrayAppend(f.tags, 'c'), 'a') })) + .where((f, fns) => fns.eq(f.id, 1)) + .build(), + ); + + expect(capturedSql.some((s) => s.includes('array_append('))).toBe(true); + expect(capturedSql.some((s) => s.includes('array_remove('))).toBe(true); + + const rows = await runtime.execute( + builder.public.item + .select('id', 'tags') + .where((f, fns) => fns.eq(f.id, 1)) + .build(), + ); + expect(rows).toEqual([{ id: 1, tags: ['b', 'c'] }]); + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'replaces the whole list column with a raw array literal', + async () => { + if (!database) throw new Error('database not initialised'); + + await withClient(database.connectionString, async (client) => { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + }); + await migrateContract(database.connectionString); + + await withClient(database.connectionString, async (client) => { + const runtime = await createTestRuntimeFromClient( + contract as FrameworkContract, + client, + { verifyMarker: false }, + ); + + const context = createExecutionContext({ + contract, + stack: createSqlExecutionStack({ + target: postgresRuntimeTarget, + adapter: postgresRuntimeAdapter, + extensionPacks: [], + }), + }); + + const builder = sqlBuilder({ context, rawCodecInferer: postgresRawCodecInferer }); + + await runtime.execute( + builder.public.item.insert([{ id: 1, tags: ['a', 'b'], scores: [1] }]).build(), + ); + + await runtime.execute( + builder.public.item + .update({ tags: ['x', 'y'] }) + .where((f, fns) => fns.eq(f.id, 1)) + .build(), + ); + + const rows = await runtime.execute( + builder.public.item + .select('id', 'tags') + .where((f, fns) => fns.eq(f.id, 1)) + .build(), + ); + expect(rows).toEqual([{ id: 1, tags: ['x', 'y'] }]); + }); + }, + timeouts.spinUpPpgDev, + ); +}); diff --git a/test/integration/test/scalar-lists/scalar-list-ops.types.test-d.ts b/test/integration/test/scalar-lists/scalar-list-ops.types.test-d.ts new file mode 100644 index 0000000000..b7dfcd3273 --- /dev/null +++ b/test/integration/test/scalar-lists/scalar-list-ops.types.test-d.ts @@ -0,0 +1,186 @@ +/** + * End-to-end type-test for the native list ops (`has`, `arrayContains`, + * `containedBy`, `overlaps`, `length`, `index`), driven through the REAL emitted + * contract and + * the REAL sql-builder `.where((f, fns) => …)` / `.select(alias, (f, fns) => …)` + * surfaces — no synthetic scope. At the type level: + * + * - `f.tags` (a `many: true` column) surfaces as a list receiver, so each op + * type-checks and yields the declared return expression. + * - a scalar column receiver (`f.id`) is a compile error. + * - a wrong-typed element/array/index is a compile error. + * + * Each op is the postgres adapter's registry op (`descriptor-meta.ts`), + * surfaced verbatim by `DeriveExtFunctions` from the contract's + * `queryOperationTypes`. + */ + +import type { BooleanCodecType, Db, Expression } from '@prisma-next/sql-builder/types'; +import { expectTypeOf, test } from 'vitest'; +import type { Contract } from '../sql-orm-client/fixtures/scalar-lists/generated/contract'; + +declare const db: Db; + +test('has membership resolves over a list column in a where-callback body', () => { + db.public.item.select('id').where((f, fns) => { + const result = fns.has(f.tags, 'react'); + expectTypeOf(result).toExtend>(); + return result; + }); + // an Int[] list accepts an int element + db.public.item.select('id').where((f, fns) => fns.has(f.scores, 1)); +}); + +test('has rejects a scalar receiver', () => { + db.public.item.select('id').where((f, fns) => + // @ts-expect-error -- id is a scalar column, not a list receiver + fns.has(f.id, 1), + ); +}); + +test('has rejects a wrong-typed element', () => { + db.public.item.select('id').where((f, fns) => + // @ts-expect-error -- tags is a text list; the element must be a string + fns.has(f.tags, 5), + ); +}); + +test('array filters (arrayContains/containedBy/overlaps) resolve over a list column', () => { + db.public.item.select('id').where((f, fns) => { + const contained = fns.containedBy(f.tags, ['react', 'vue']); + expectTypeOf(contained).toExtend>(); + const superset = fns.arrayContains(f.tags, ['react']); + expectTypeOf(superset).toExtend>(); + return fns.and( + contained, + superset, + fns.overlaps(f.tags, ['svelte']), + // an Int[] list accepts an int array operand + fns.containedBy(f.scores, [1, 2]), + fns.arrayContains(f.scores, [1]), + // a list-typed operand (another list column) is accepted + fns.overlaps(f.tags, f.tags), + fns.arrayContains(f.tags, f.tags), + ); + }); +}); + +test('array filters reject a scalar receiver', () => { + db.public.item.select('id').where((f, fns) => + // @ts-expect-error -- id is a scalar column, not a list receiver + fns.overlaps(f.id, [1]), + ); + db.public.item.select('id').where((f, fns) => + // @ts-expect-error -- id is a scalar column, not a list receiver + fns.arrayContains(f.id, [1]), + ); +}); + +test('array filters reject a wrong-typed array element', () => { + db.public.item.select('id').where((f, fns) => + // @ts-expect-error -- tags is a text list; the array elements must be strings + fns.containedBy(f.tags, [5]), + ); + db.public.item.select('id').where((f, fns) => + // @ts-expect-error -- tags is a text list; the array elements must be strings + fns.arrayContains(f.tags, [5]), + ); +}); + +test('length yields a non-null int over a list column', () => { + db.public.item.select('n', (f, fns) => { + const len = fns.length(f.tags); + expectTypeOf(len).toExtend>(); + return len; + }); +}); + +test('length rejects a scalar receiver', () => { + db.public.item.select('n', (f, fns) => + // @ts-expect-error -- id is a scalar column, not a list receiver + fns.length(f.id), + ); +}); + +test('index yields the nullable element codec over a list column', () => { + db.public.item.select('first', (f, fns) => { + const firstTag = fns.index(f.tags, 1); + expectTypeOf(firstTag).toExtend>(); + return firstTag; + }); + db.public.item.select('firstScore', (f, fns) => { + const firstScore = fns.index(f.scores, 1); + expectTypeOf(firstScore).toExtend>(); + return firstScore; + }); +}); + +test('index rejects a scalar receiver', () => { + db.public.item.select('first', (f, fns) => + // @ts-expect-error -- id is a scalar column, not a list receiver + fns.index(f.id, 1), + ); +}); + +test('index rejects a non-int index', () => { + db.public.item.select('first', (f, fns) => + // @ts-expect-error -- the index must be an integer expression, not a string + fns.index(f.scores, 'x'), + ); +}); + +test('arrayAppend/arrayRemove yield a list expression accepted in an update set map', () => { + db.public.item.update((f, fns) => { + const appended = fns.arrayAppend(f.tags, 'x'); + expectTypeOf(appended).toExtend< + Expression<{ codecId: 'pg/text@1'; nullable: false; many: true }> + >(); + return { tags: appended }; + }); + // nested compose: append then remove, both over the same text list + db.public.item.update((f, fns) => ({ + tags: fns.arrayRemove(fns.arrayAppend(f.tags, 'x'), 'y'), + })); + // an Int[] list accepts an int element + db.public.item.update((f, fns) => ({ scores: fns.arrayAppend(f.scores, 1) })); +}); + +test('arrayAppend rejects a wrong-typed element', () => { + db.public.item.update((f, fns) => ({ + // @ts-expect-error -- tags is a text list; the element must be a string + tags: fns.arrayAppend(f.tags, 5), + })); +}); + +test('a list mutator is rejected for a scalar column', () => { + // @ts-expect-error -- id is a scalar int column; a text list mutator is not assignable + db.public.item.update((f, fns) => ({ + id: fns.arrayAppend(f.tags, 'x'), + })); +}); + +test('comparison builtins accept a whole-list operand (FR16)', () => { + db.public.item.select('id').where((f, fns) => { + const byLiteral = fns.eq(f.tags, ['a', 'b']); + expectTypeOf(byLiteral).toExtend>(); + const byExpr = fns.eq(f.tags, f.tags); + expectTypeOf(byExpr).toExtend>(); + // ordering compares lexicographically over an orderable element list + const ordered = fns.gt(f.tags, ['a']); + expectTypeOf(ordered).toExtend>(); + return fns.and(byLiteral, byExpr, ordered, fns.eq(f.scores, [1, 2])); + }); + // scalar comparison calls remain unchanged + db.public.item.select('id').where((f, fns) => fns.eq(f.id, 1)); +}); + +test('comparison builtins reject a wrong-typed list element', () => { + db.public.item.select('id').where((f, fns) => + // @ts-expect-error -- tags is a text list; the array elements must be strings + fns.eq(f.tags, [5]), + ); + db.public.item.select('id').where((f, fns) => + // @ts-expect-error -- tags is a text list; the array elements must be strings + fns.gt(f.tags, [5]), + ); +}); diff --git a/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.d.ts b/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.d.ts index d59cdb3a82..26d8cf8ae9 100644 --- a/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.d.ts +++ b/test/integration/test/sql-orm-client/fixtures/scalar-lists/generated/contract.d.ts @@ -106,11 +106,13 @@ type ContractBase = Omit< readonly nativeType: 'text'; readonly codecId: 'pg/text@1'; readonly nullable: false; + readonly many: true; }; readonly scores: { readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; + readonly many: true; }; }; primaryKey: { readonly columns: readonly ['id'] };