diff --git a/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index 532d425d76..4b0198ece2 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -16,6 +16,7 @@ import type { Interval } from '@prisma-next/adapter-postgres/codec-types'; import type { CodecTypes as PgVectorTypes } from '@prisma-next/extension-pgvector/codec-types'; import type { Vector } from '@prisma-next/extension-pgvector/codec-types'; import type { OperationTypes as PgVectorOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; import type { @@ -39,7 +40,7 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgVectorQueryOperationTypes; +export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/packages/1-framework/1-core/operations/src/index.ts b/packages/1-framework/1-core/operations/src/index.ts index 7079e77f8e..1f2222b289 100644 --- a/packages/1-framework/1-core/operations/src/index.ts +++ b/packages/1-framework/1-core/operations/src/index.ts @@ -1,11 +1,17 @@ export interface ParamSpec { + readonly codecId?: string; + readonly traits?: readonly string[]; + readonly nullable: boolean; +} + +export interface ReturnSpec { readonly codecId: string; readonly nullable: boolean; } export interface OperationEntry { readonly args: readonly ParamSpec[]; - readonly returns: ParamSpec; + readonly returns: ReturnSpec; } export type OperationDescriptor = T & { diff --git a/packages/2-sql/1-core/contract/src/exports/types.ts b/packages/2-sql/1-core/contract/src/exports/types.ts index 0393c305c2..3bd43c154e 100644 --- a/packages/2-sql/1-core/contract/src/exports/types.ts +++ b/packages/2-sql/1-core/contract/src/exports/types.ts @@ -12,6 +12,7 @@ export type { Index, OperationTypesOf, PrimaryKey, + QueryOperationArgSpec, QueryOperationTypeEntry, QueryOperationTypesBase, QueryOperationTypesOf, diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index 83dd29cca9..ccc6052728 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -1,4 +1,5 @@ import type { ColumnDefault, StorageBase } from '@prisma-next/contract/types'; +import type { CodecTrait } from '@prisma-next/framework-components/codec'; /** * A column definition in storage. @@ -164,8 +165,14 @@ export type OperationTypesOf = [T] extends [never] : Record : Record; +export type QueryOperationArgSpec = { + readonly codecId?: string; + readonly traits?: CodecTrait; + readonly nullable: boolean; +}; + export type QueryOperationTypeEntry = { - readonly args: readonly { readonly codecId: string; readonly nullable: boolean }[]; + readonly args: readonly QueryOperationArgSpec[]; readonly returns: { readonly codecId: string; readonly nullable: boolean }; }; diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/types.ts index bd1cf70566..8ea44418fc 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/types.ts @@ -4,17 +4,7 @@ import type { SqlLoweringSpec } from '@prisma-next/sql-operations'; export type Direction = 'asc' | 'desc'; -export type BinaryOp = - | 'eq' - | 'neq' - | 'gt' - | 'lt' - | 'gte' - | 'lte' - | 'like' - | 'ilike' - | 'in' - | 'notIn'; +export type BinaryOp = 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'like' | 'in' | 'notIn'; export type AggregateCountFn = 'count'; export type AggregateOpFn = 'sum' | 'avg' | 'min' | 'max'; @@ -801,10 +791,6 @@ export class BinaryExpr extends Expression { return new BinaryExpr('like', left, right); } - static ilike(left: AnyExpression, right: AnyExpression): BinaryExpr { - return new BinaryExpr('ilike', left, right); - } - static in(left: AnyExpression, right: AnyExpression): BinaryExpr { return new BinaryExpr('in', left, right); } diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/predicate.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/predicate.test.ts index 1d72a1378c..637e673538 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/predicate.test.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/predicate.test.ts @@ -29,7 +29,6 @@ describe('ast/predicate', () => { 'gte', 'lte', 'like', - 'ilike', 'in', 'notIn', ] as const)('stores the %s operator', (op) => { 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 f22297da53..d6d08bd635 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/expression.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/expression.ts @@ -2,6 +2,7 @@ import type { QueryOperationTypesBase } from '@prisma-next/sql-contract/types'; import type { Expand, ExpressionType, + FieldSpec, QueryContext, Scope, ScopeField, @@ -9,7 +10,7 @@ import type { Subquery, } from './scope'; -export type Expression = { +export type Expression = { [ExpressionType]: T; buildAst(): import('@prisma-next/sql-relational-core/ast').AnyExpression; }; @@ -66,10 +67,33 @@ export type OrderByScope< namespaces: AvailableScope['namespaces']; }; +type CodecIdsWithTrait< + CT extends Record, + Trait extends string, +> = { + [K in keyof CT & string]: CT[K] extends { readonly traits: infer T } + ? Trait extends T + ? K + : never + : never; +}[keyof CT & string]; + +type ResolveExtArg> = Arg extends { + readonly codecId: infer CId extends string; + readonly nullable: infer N extends boolean; +} + ? ExpressionOrValue<{ codecId: CId; nullable: N }, CT> + : Arg extends { + readonly traits: infer T extends string; + readonly nullable: infer N extends boolean; + } + ? ExpressionOrValue<{ codecId: CodecIdsWithTrait; nullable: N }, CT> + : never; + type ExtensionFunctionArgs< - Args extends readonly ScopeField[], + Args extends readonly unknown[], CT extends Record, -> = { [I in keyof Args]: ExpressionOrValue }; +> = { [I in keyof Args]: ResolveExtArg }; type DeriveExtFunctions< OT extends QueryOperationTypesBase, diff --git a/packages/2-sql/4-lanes/sql-builder/src/scope.ts b/packages/2-sql/4-lanes/sql-builder/src/scope.ts index 2286e4aa9b..aea8bf6eea 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/scope.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/scope.ts @@ -14,6 +14,8 @@ export type Expand = { [K in keyof T]: T[K] } & unknown; export type EmptyRow = Record; export type ScopeField = { codecId: string; nullable: boolean }; +export type TraitField = { traits: string; nullable: boolean }; +export type FieldSpec = ScopeField | TraitField; export type ScopeTable = Record; export type Scope = { diff --git a/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts b/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts index adf55fbbef..09e844ecbf 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts @@ -16,6 +16,7 @@ import type { Interval } from '@prisma-next/adapter-postgres/codec-types'; import type { CodecTypes as PgVectorTypes } from '@prisma-next/extension-pgvector/codec-types'; import type { Vector } from '@prisma-next/extension-pgvector/codec-types'; import type { OperationTypes as PgVectorOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; import type { @@ -39,7 +40,7 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgVectorQueryOperationTypes; +export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/packages/2-sql/4-lanes/sql-builder/test/integration/extension-functions.test.ts b/packages/2-sql/4-lanes/sql-builder/test/integration/extension-functions.test.ts index fade0931d3..3547ffd70b 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/integration/extension-functions.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/integration/extension-functions.test.ts @@ -1,6 +1,31 @@ import { describe, expect, it } from 'vitest'; import { setupIntegrationTest } from './setup'; +describe('integration: ilike (adapter operation)', () => { + const { db, runtime } = setupIntegrationTest(); + + it('ilike filters case-insensitively in WHERE', async () => { + const rows = await runtime().execute( + db() + .users.select('id', 'name') + .where((f, fns) => fns.ilike(f.name, '%alice%')) + .build(), + ); + expect(rows).toHaveLength(1); + expect(rows[0]!.name).toBe('Alice'); + }); + + it('ilike returns no rows when pattern does not match', async () => { + const rows = await runtime().execute( + db() + .users.select('id') + .where((f, fns) => fns.ilike(f.name, '%zzz%')) + .build(), + ); + expect(rows).toHaveLength(0); + }); +}); + describe('integration: extension functions', () => { const { db, runtime } = setupIntegrationTest(); diff --git a/packages/2-sql/4-lanes/sql-builder/test/playground/extension-functions.test-d.ts b/packages/2-sql/4-lanes/sql-builder/test/playground/extension-functions.test-d.ts index 23999b3450..e84da42ac2 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/playground/extension-functions.test-d.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/playground/extension-functions.test-d.ts @@ -1,5 +1,6 @@ import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import { expectTypeOf, test } from 'vitest'; +import type { BooleanCodecType, Expression } from '../../src/expression'; import { db } from './preamble'; test('extension function in select expression', () => { @@ -28,3 +29,20 @@ test('extension function composed with builtins in where', () => { expectTypeOf(filtered).toEqualTypeOf>(); }); + +test('ilike filters text fields in where', () => { + const filtered = db.users + .select('id', 'name') + .where((f, fns) => fns.ilike(f.name, '%alice%')) + .build(); + + expectTypeOf(filtered).toEqualTypeOf>(); +}); + +test('ilike returns boolean expression', () => { + db.users.select('id').where((f, fns) => { + const result = fns.ilike(f.name, '%test%'); + expectTypeOf(result).toExtend>(); + return result; + }); +}); diff --git a/packages/3-extensions/sql-orm-client/src/model-accessor.ts b/packages/3-extensions/sql-orm-client/src/model-accessor.ts index a2296f1644..73648fe15d 100644 --- a/packages/3-extensions/sql-orm-client/src/model-accessor.ts +++ b/packages/3-extensions/sql-orm-client/src/model-accessor.ts @@ -54,15 +54,28 @@ export function createModelAccessor< >; const opsByCodecId = new Map(); + + function registerOp(codecId: string, op: [string, SqlOperationEntry]) { + let existing = opsByCodecId.get(codecId); + if (!existing) { + existing = []; + opsByCodecId.set(codecId, existing); + } + existing.push(op); + } + for (const [name, entry] of Object.entries(context.queryOperations.entries())) { - const selfCodecId = entry.args[0]?.codecId; - if (selfCodecId) { - let existing = opsByCodecId.get(selfCodecId); - if (!existing) { - existing = []; - opsByCodecId.set(selfCodecId, existing); + const self = entry.args[0]; + const op: [string, SqlOperationEntry] = [name, entry]; + if (self?.codecId) { + registerOp(self.codecId, op); + } else if (self?.traits) { + for (const codec of context.codecs.values()) { + const codecTraits: readonly string[] = codec.traits ?? []; + if (self.traits.every((t) => codecTraits.includes(t))) { + registerOp(codec.id, op); + } } - existing.push([name, entry]); } } @@ -137,11 +150,14 @@ function createExtensionMethodFactory( methodName: string, entry: SqlOperationEntry, context: ExecutionContext, -): (...args: unknown[]) => Partial> { +): (...args: unknown[]) => unknown { + const returnTraits = context.codecs.traitsOf(entry.returns.codecId); + const isPredicate = returnTraits.includes('boolean'); + return (...args: unknown[]) => { const userArgSpecs = entry.args.slice(1); const astArgs = userArgSpecs.map((argSpec, i) => { - return ParamRef.of(args[i], { codecId: argSpec.codecId }); + return ParamRef.of(args[i], argSpec.codecId ? { codecId: argSpec.codecId } : undefined); }); const opExpr = new OperationExpr({ @@ -152,7 +168,10 @@ function createExtensionMethodFactory( lowering: entry.lowering, }); - const returnTraits = context.codecs.traitsOf(entry.returns.codecId); + if (isPredicate) { + return opExpr; + } + const methods: Record = {}; for (const [resultMethodName, meta] of Object.entries(COMPARISON_METHODS_META)) { diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index 3a76d01700..fe3b3dd30e 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -155,7 +155,6 @@ export type ComparisonMethodFns = { gte(value: T): AnyExpression; lte(value: T): AnyExpression; like(pattern: string): AnyExpression; - ilike(pattern: string): AnyExpression; in(values: readonly T[]): AnyExpression; notIn(values: readonly T[]): AnyExpression; isNull(): AnyExpression; @@ -224,18 +223,48 @@ type MapArgsToJsTypes< ? [CodecArgJsType, ...MapArgsToJsTypes] : []; +type IsBooleanReturn> = Returns extends { + readonly codecId: infer Id extends string; +} + ? Id extends keyof TCodecTypes + ? TCodecTypes[Id] extends { readonly traits: infer T } + ? 'boolean' extends T + ? true + : false + : false + : false + : false; + type QueryOperationMethod> = Op extends { readonly args: readonly [unknown, ...infer UserArgs]; readonly returns: infer Returns; } - ? ( - ...args: MapArgsToJsTypes - ) => ComparisonMethods< - QueryOperationReturnJsType, - QueryOperationReturnTraits - > + ? IsBooleanReturn extends true + ? (...args: MapArgsToJsTypes) => AnyExpression + : ( + ...args: MapArgsToJsTypes + ) => ComparisonMethods< + QueryOperationReturnJsType, + QueryOperationReturnTraits + > : never; +type OpMatchesField> = Op extends { + readonly args: readonly [infer Self, ...(readonly unknown[])]; +} + ? Self extends { readonly codecId: CodecId } + ? true + : Self extends { readonly traits: infer RequiredTraits extends string } + ? CodecId extends keyof CT + ? CT[CodecId] extends { readonly traits: infer FieldTraits } + ? RequiredTraits extends FieldTraits + ? true + : false + : false + : false + : false + : false; + type FieldOperations< TContract extends Contract, ModelName extends string, @@ -243,9 +272,11 @@ type FieldOperations< > = FieldCodecId extends infer CodecId extends string ? ExtractQueryOperationTypes extends infer AllOps ? { - [OpName in keyof AllOps & string as AllOps[OpName] extends { - readonly args: readonly [{ readonly codecId: CodecId }, ...(readonly unknown[])]; - } + [OpName in keyof AllOps & string as OpMatchesField< + AllOps[OpName], + CodecId, + ExtractCodecTypes + > extends true ? OpName : never]: QueryOperationMethod>; } @@ -330,10 +361,6 @@ export const COMPARISON_METHODS_META = { traits: ['textual'], create: scalarComparisonMethod('like'), }, - ilike: { - traits: ['textual'], - create: scalarComparisonMethod('ilike'), - }, asc: { traits: ['order'], create: (left) => () => OrderByItem.asc(left), diff --git a/packages/3-extensions/sql-orm-client/test/extension-operations.test-d.ts b/packages/3-extensions/sql-orm-client/test/extension-operations.test-d.ts index 9ef6d9ef0e..4af9003a58 100644 --- a/packages/3-extensions/sql-orm-client/test/extension-operations.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/extension-operations.test-d.ts @@ -83,11 +83,33 @@ describe('extension ops return ComparisonMethods with return-codec traits', () = expectTypeOf().not.toHaveProperty('like'); }); - test('cosineDistance result does not expose ilike (textual-only)', () => { + test('cosineDistance result does not expose ilike (extension op, not comparison method)', () => { expectTypeOf().not.toHaveProperty('ilike'); }); }); +describe('ilike extension operation on text fields', () => { + test('text field exposes ilike', () => { + expectTypeOf().toHaveProperty('ilike'); + }); + + test('ilike returns AnyExpression (predicate)', () => { + type IlikeFn = PostAccessor['title']['ilike']; + expectTypeOf().toBeFunction(); + expectTypeOf>().toExtend< + import('@prisma-next/sql-relational-core/ast').AnyExpression + >(); + }); + + test('numeric field does not expose ilike', () => { + expectTypeOf().not.toHaveProperty('ilike'); + }); + + test('vector field does not expose ilike', () => { + expectTypeOf().not.toHaveProperty('ilike'); + }); +}); + describe('vector field itself: only equality trait', () => { test('vector field exposes eq', () => { expectTypeOf().toHaveProperty('eq'); diff --git a/packages/3-extensions/sql-orm-client/test/filters.test.ts b/packages/3-extensions/sql-orm-client/test/filters.test.ts index a0d3c640a3..ae219138a1 100644 --- a/packages/3-extensions/sql-orm-client/test/filters.test.ts +++ b/packages/3-extensions/sql-orm-client/test/filters.test.ts @@ -105,17 +105,12 @@ describe('filters', () => { ); }); - it('wraps like and ilike in NotExpr', () => { + it('wraps like in NotExpr', () => { const user = createModelAccessor(context, 'User'); expect(not(user['name']!.like('%a%'))).toEqual( new NotExpr(BinaryExpr.like(ColumnRef.of('users', 'name'), paramRef('users', 'name', '%a%'))), ); - expect(not(user['name']!.ilike('%a%'))).toEqual( - new NotExpr( - BinaryExpr.ilike(ColumnRef.of('users', 'name'), paramRef('users', 'name', '%a%')), - ), - ); }); it('shorthandToWhereExpr() maps nulls, skips undefined, and combines multiple fields', () => { diff --git a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts index 74a206345a..73fbab51f7 100644 --- a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts +++ b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts @@ -16,6 +16,7 @@ import type { Interval } from '@prisma-next/adapter-postgres/codec-types'; import type { CodecTypes as PgVectorTypes } from '@prisma-next/extension-pgvector/codec-types'; import type { Vector } from '@prisma-next/extension-pgvector/codec-types'; import type { OperationTypes as PgVectorOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; import type { @@ -39,7 +40,7 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgVectorQueryOperationTypes; +export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts b/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts index 85118b1cb0..7840c1c3e6 100644 --- a/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts @@ -354,7 +354,7 @@ userCollection.where((u) => u.metadata.gt(1)); userCollection.where((u) => u.metadata.gte(1)); // @ts-expect-error jsonb has no textual trait → like not available userCollection.where((u) => u.metadata.like('%')); -// @ts-expect-error jsonb has no textual trait → ilike not available +// @ts-expect-error jsonb has no textual trait → ilike extension op not available userCollection.where((u) => u.metadata.ilike('%')); // @ts-expect-error jsonb has no order trait → asc not available userCollection.orderBy((u) => u.metadata.asc()); diff --git a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts index d6acb87cf7..aed8c5266a 100644 --- a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts @@ -62,7 +62,22 @@ describe('createModelAccessor', () => { expectBinaryParam(post['id']!.lte(10), 'posts', 'id', 'lte', 10); expectBinaryParam(post['userId']!.eq(42), 'posts', 'user_id', 'eq', 42); expectBinaryParam(user['name']!.like('%Ali%'), 'users', 'name', 'like', '%Ali%'); - expectBinaryParam(user['name']!.ilike('%ali%'), 'users', 'name', 'ilike', '%ali%'); + }); + + it('creates ilike as trait-matched extension operation returning predicate', () => { + const user = createModelAccessor(context, 'User'); + const ilike = user['name']!.ilike; + const result = ilike('%ali%'); + expect(result).toBeInstanceOf(OperationExpr); + const op = result as OperationExpr; + expect(op.method).toBe('ilike'); + expect(op.self).toEqual(ColumnRef.of('users', 'name')); + }); + + it('does not expose ilike on non-textual fields', () => { + const post = createModelAccessor(context, 'Post'); + const field = post['views'] as unknown as Record; + expect(field['ilike']).toBeUndefined(); }); it('creates list literal, null check, and order directive helpers', () => { @@ -403,7 +418,6 @@ describe('createModelAccessor', () => { expect(field['gte']).toBeUndefined(); expect(field['lte']).toBeUndefined(); expect(field['like']).toBeUndefined(); - expect(field['ilike']).toBeUndefined(); expect(field['asc']).toBeUndefined(); expect(field['desc']).toBeUndefined(); }); @@ -423,7 +437,6 @@ describe('createModelAccessor', () => { 'gte', 'lte', 'like', - 'ilike', 'in', 'notIn', 'isNull', diff --git a/packages/3-extensions/sql-orm-client/test/orm.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/orm.types.test-d.ts index a74cddb772..9d6cf753a0 100644 --- a/packages/3-extensions/sql-orm-client/test/orm.types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/orm.types.test-d.ts @@ -57,7 +57,7 @@ db.Post.where((post) => post.views.isNotNull()); // @ts-expect-error int4 has no textual trait → like() not available db.Post.where((post) => post.views.like('%')); -// @ts-expect-error int4 has no textual trait → ilike() not available +// @ts-expect-error int4 has no textual trait → ilike extension op not available db.Post.where((post) => post.views.ilike('%')); // text has no numeric trait → sum/avg restricted diff --git a/packages/3-extensions/sql-orm-client/test/where-binding.test.ts b/packages/3-extensions/sql-orm-client/test/where-binding.test.ts index 3bb24eb3c7..9f3c9d4082 100644 --- a/packages/3-extensions/sql-orm-client/test/where-binding.test.ts +++ b/packages/3-extensions/sql-orm-client/test/where-binding.test.ts @@ -463,14 +463,6 @@ describe('bindWhereExpr', () => { expect((bound.right as ParamRef).value).toBe('%alice%'); }); - it('ilike binds literal to param', () => { - const expr = BinaryExpr.ilike(ColumnRef.of('users', 'name'), LiteralExpr.of('%alice%')); - const bound = bindWhereExpr(contract, expr) as BinaryExpr; - - expect(bound.op).toBe('ilike'); - expect(bound.right.kind).toBe('param-ref'); - }); - it('notIn binds list literals to params', () => { const expr = BinaryExpr.notIn( ColumnRef.of('users', 'id'), diff --git a/packages/3-targets/6-adapters/postgres/package.json b/packages/3-targets/6-adapters/postgres/package.json index 7059618af6..f1ca23407e 100644 --- a/packages/3-targets/6-adapters/postgres/package.json +++ b/packages/3-targets/6-adapters/postgres/package.json @@ -48,6 +48,7 @@ "./codec-types": "./dist/codec-types.mjs", "./column-types": "./dist/column-types.mjs", "./control": "./dist/control.mjs", + "./operation-types": "./dist/operation-types.mjs", "./runtime": "./dist/runtime.mjs", "./types": "./dist/types.mjs", "./package.json": "./package.json" diff --git a/packages/3-targets/6-adapters/postgres/src/core/adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/adapter.ts index 4eba88ec05..c81baa4635 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/adapter.ts @@ -340,7 +340,6 @@ function renderBinary(expr: BinaryExpr, contract?: PostgresContract, pim?: Param gte: '>=', lte: '<=', like: 'LIKE', - ilike: 'ILIKE', in: 'IN', notIn: 'NOT IN', }; 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 f48adc60ad..c66529db2e 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,4 +1,5 @@ import type { CodecControlHooks, ExpandNativeTypeInput } from '@prisma-next/family-sql/control'; +import type { SqlOperationDescriptor } from '@prisma-next/sql-operations'; import { PG_BIT_CODEC_ID, PG_BOOL_CODEC_ID, @@ -127,6 +128,18 @@ const identityHooks: CodecControlHooks = { expandNativeType: ({ nativeType }) => // Descriptor metadata // ============================================================================ +export const postgresQueryOperations: readonly SqlOperationDescriptor[] = [ + { + method: 'ilike', + args: [ + { traits: ['textual'], nullable: false }, + { codecId: PG_TEXT_CODEC_ID, nullable: false }, + ], + returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, + lowering: { targetFamily: 'sql', strategy: 'infix', template: '{{self}} ILIKE {{arg0}}' }, + }, +]; + export const postgresAdapterDescriptorMeta = { kind: 'adapter', familyId: 'sql', @@ -255,5 +268,12 @@ export const postgresAdapterDescriptorMeta = { { typeId: PG_JSON_CODEC_ID, familyId: 'sql', targetId: 'postgres', nativeType: 'json' }, { typeId: PG_JSONB_CODEC_ID, familyId: 'sql', targetId: 'postgres', nativeType: 'jsonb' }, ], + queryOperationTypes: { + import: { + package: '@prisma-next/adapter-postgres/operation-types', + named: 'QueryOperationTypes', + alias: 'PgAdapterQueryOps', + }, + }, }, } as const; diff --git a/packages/3-targets/6-adapters/postgres/src/exports/operation-types.ts b/packages/3-targets/6-adapters/postgres/src/exports/operation-types.ts new file mode 100644 index 0000000000..38dc6e7229 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/src/exports/operation-types.ts @@ -0,0 +1 @@ +export type { QueryOperationTypes } from '../types/operation-types'; diff --git a/packages/3-targets/6-adapters/postgres/src/exports/runtime.ts b/packages/3-targets/6-adapters/postgres/src/exports/runtime.ts index c35054e04f..616555f4fa 100644 --- a/packages/3-targets/6-adapters/postgres/src/exports/runtime.ts +++ b/packages/3-targets/6-adapters/postgres/src/exports/runtime.ts @@ -12,7 +12,7 @@ import { type as arktype } from 'arktype'; import { createPostgresAdapter } from '../core/adapter'; import { PG_JSON_CODEC_ID, PG_JSONB_CODEC_ID } from '../core/codec-ids'; import { codecDefinitions } from '../core/codecs'; -import { postgresAdapterDescriptorMeta } from '../core/descriptor-meta'; +import { postgresAdapterDescriptorMeta, postgresQueryOperations } from '../core/descriptor-meta'; import { compileJsonSchemaValidator, type JsonSchemaValidateFn, @@ -79,6 +79,7 @@ const postgresRuntimeAdapterDescriptor: SqlRuntimeAdapterDescriptor<'postgres', ...postgresAdapterDescriptorMeta, codecs: createPostgresCodecRegistry, parameterizedCodecs: () => parameterizedCodecDescriptors, + queryOperations: () => postgresQueryOperations, mutationDefaultGenerators: createPostgresMutationDefaultGenerators, create(): SqlRuntimeAdapter { return createPostgresAdapter(); 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 new file mode 100644 index 0000000000..7f4110a6cc --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/src/types/operation-types.ts @@ -0,0 +1,11 @@ +import type { SqlQueryOperationTypes } from '@prisma-next/sql-contract/types'; + +export type QueryOperationTypes = SqlQueryOperationTypes<{ + readonly ilike: { + readonly args: readonly [ + { readonly traits: 'textual'; readonly nullable: boolean }, + { readonly codecId: 'pg/text@1'; readonly nullable: false }, + ]; + readonly returns: { readonly codecId: 'pg/bool@1'; readonly nullable: false }; + }; +}>; diff --git a/packages/3-targets/6-adapters/postgres/tsdown.config.ts b/packages/3-targets/6-adapters/postgres/tsdown.config.ts index ce4cdb4d0a..02ebbd31d8 100644 --- a/packages/3-targets/6-adapters/postgres/tsdown.config.ts +++ b/packages/3-targets/6-adapters/postgres/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ 'src/exports/codec-types.ts', 'src/exports/column-types.ts', 'src/exports/control.ts', + 'src/exports/operation-types.ts', 'src/exports/runtime.ts', ], }); diff --git a/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts b/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts index 31508d0ac9..9898dc936b 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts @@ -319,12 +319,6 @@ function renderBinary(expr: BinaryExpr, contract?: SqliteContract): string { break; } - // ilike is not supported by SQLite — waiting for prisma/prisma-next#277 - // to move ilike into an extension so it can be properly capability-gated. - if (expr.op === 'ilike') { - throw new Error('SQLite does not support ILIKE. Use LIKE for case-sensitive matching.'); - } - const operatorMap: Record = { eq: '=', neq: '!=', @@ -333,7 +327,6 @@ function renderBinary(expr: BinaryExpr, contract?: SqliteContract): string { gte: '>=', lte: '<=', like: 'LIKE', - ilike: 'LIKE', // unreachable — guarded above; kept to satisfy Record in: 'IN', notIn: 'NOT IN', }; diff --git a/packages/3-targets/6-adapters/sqlite/test/adapter.test.ts b/packages/3-targets/6-adapters/sqlite/test/adapter.test.ts index fa509fa4a6..a3edd574a5 100644 --- a/packages/3-targets/6-adapters/sqlite/test/adapter.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/adapter.test.ts @@ -416,14 +416,6 @@ describe('SQLite adapter', () => { expect(sql).toContain('= ?'); }); - it('throws on ILIKE (not supported by SQLite)', () => { - const ast = SelectAst.from(TableSource.named('user')) - .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) - .withWhere(BinaryExpr.ilike(ColumnRef.of('user', 'email'), ParamRef.of('%test%'))); - - expect(() => adapter.lower(ast, { contract })).toThrow('SQLite does not support ILIKE'); - }); - it('throws on unsupported AST node kind', () => { const unsupported = { kind: 'unsupported', diff --git a/test/e2e/framework/test/fixtures/generated/contract.d.ts b/test/e2e/framework/test/fixtures/generated/contract.d.ts index 95eaea3d7d..6567c7c082 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/fixtures/generated/contract.d.ts @@ -16,6 +16,7 @@ import type { Interval } from '@prisma-next/adapter-postgres/codec-types'; import type { CodecTypes as PgVectorTypes } from '@prisma-next/extension-pgvector/codec-types'; import type { Vector } from '@prisma-next/extension-pgvector/codec-types'; import type { OperationTypes as PgVectorOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; import type { @@ -39,7 +40,7 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgVectorQueryOperationTypes; +export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/test/integration/test/fixtures/contract.d.ts b/test/integration/test/fixtures/contract.d.ts index b1f622da1c..0276d3495e 100644 --- a/test/integration/test/fixtures/contract.d.ts +++ b/test/integration/test/fixtures/contract.d.ts @@ -13,6 +13,7 @@ import type { Timestamptz } from '@prisma-next/adapter-postgres/codec-types'; import type { Time } from '@prisma-next/adapter-postgres/codec-types'; import type { Timetz } from '@prisma-next/adapter-postgres/codec-types'; import type { Interval } from '@prisma-next/adapter-postgres/codec-types'; +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; import type { ContractWithTypeMaps, @@ -34,7 +35,7 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type OperationTypes = Record; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = Record; +export type QueryOperationTypes = PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded;