From 5251e68c1cef59228981379f67e6a0be3869c7b3 Mon Sep 17 00:00:00 2001 From: Alexandre Figueiredo Date: Tue, 5 May 2026 15:20:44 +0200 Subject: [PATCH 1/4] chore: update prisma schema --- prisma/schema.prisma | 13 +++++++++++++ testing/mongodb/schema.prisma | 13 +++++++++++++ testing/mysql/schema.prisma | 13 +++++++++++++ 3 files changed, 39 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5952082c..49e9ca86 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,7 @@ model User { service Service[] subscriptions Subscription[] reactions Reaction[] + element Element[] } model Profile { @@ -101,3 +102,15 @@ model Reaction { @@id([userId, emoji]) } + +model Element { + e_id Int @id @default(autoincrement()) + + user User @relation(fields: [userId], references: [id]) + userId Int + + value String + json Json? + + @@unique([userId, value]) +} \ No newline at end of file diff --git a/testing/mongodb/schema.prisma b/testing/mongodb/schema.prisma index 7db8f1e6..8d8624e9 100755 --- a/testing/mongodb/schema.prisma +++ b/testing/mongodb/schema.prisma @@ -32,6 +32,7 @@ model User { profile Profile? services Service[] subscriptions Subscription[] + element Element[] } model Profile { @@ -89,3 +90,15 @@ model Subscription { blog Blog @relation(fields: [blogId], references: [id]) blogId String @db.ObjectId } + +model Element { + e_id Int @id @default(auto()) @map("_id") @db.ObjectId + + user User @relation(fields: [userId], references: [id]) + userId Int + + value String + json Json? + + @@unique([userId, value]) +} diff --git a/testing/mysql/schema.prisma b/testing/mysql/schema.prisma index 910a4e00..14496085 100644 --- a/testing/mysql/schema.prisma +++ b/testing/mysql/schema.prisma @@ -33,6 +33,7 @@ model User { service Service[] subscriptions Subscription[] reactions Reaction[] + element Element[] } model Profile { @@ -101,3 +102,15 @@ model Reaction { @@id([userId, emoji]) } + +model Element { + e_id Int @id @default(autoincrement()) + + user User @relation(fields: [userId], references: [id]) + userId Int + + value String + json Json? + + @@unique([userId, value]) +} From c82f9c83f030a6140eafaadcf7df22358b254a94 Mon Sep 17 00:00:00 2001 From: Alexandre Figueiredo Date: Tue, 5 May 2026 15:26:08 +0200 Subject: [PATCH 2/4] feat: add support for JSON fields filtering --- src/lib/helpers.ts | 77 ++++++++++++++++++++++ src/lib/operations/find/find.ts | 18 ++++++ src/lib/operations/find/match.ts | 108 ++++++++++++++++++++++++++++++- 3 files changed, 202 insertions(+), 1 deletion(-) diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 5bc2a3cb..fa9f38ab 100755 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -1,3 +1,5 @@ +import { Prisma } from '@prisma/client'; + import { Item } from './delegate'; export function camelize(str: string) { @@ -10,11 +12,70 @@ export function camelize(str: string) { export function shallowCompare(a: Item, b: Item) { for (const key in b) { + if (a[key] instanceof Date) { + if (b[key] === undefined) { + return false; + } + if (!(b[key] instanceof Date) || b[key].getTime() !== a[key].getTime()) { + return false; + } + continue; + } if (a[key] !== b[key]) return false; } return true; } +// Deep equality check for JSON values +export function deepEqual(a: any, b: any): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (typeof a !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!deepEqual(a[key], b[key])) return false; + } + return true; +} + +// Get value at JSON path (array of keys) +export function getJsonPath(obj: any, path: any): any { + if (!Array.isArray(path)) return undefined; + let current = obj; + for (const key of path) { + if (current == null) return undefined; + current = current[key]; + } + return current; +} + +export function objectContains(target: unknown, subObject: unknown) { + if (target === subObject) return true; + + if (isObject(target) && isObject(subObject)) { + return shallowCompare(target, subObject); + } + + return false; +} + +function isObject(value: unknown): value is Item { + if (!value) return false; + if (typeof value !== 'object') return false; + if (Array.isArray(value)) return false; + + return true; +} + export function pick(obj: Record, keys: string[]) { return Object.entries(obj).reduce((accumulator, [currentKey, currentValue]) => { if (keys.includes(currentKey)) { @@ -80,3 +141,19 @@ export function unique(value: T[]) { export function ensureArray(value: T | T[]): T[] { return Array.isArray(value) ? value : [value]; } + +export function isJsonFilter(value: unknown): value is Prisma.JsonFilter { + if (!isObject(value)) return false; + + return [ + 'path', + 'equals', + 'not', + 'string_contains', + 'string_starts_withn', + 'string_ends_with', + 'array_contains', + 'array_starts_with', + 'array_ends_with', + ].some((key) => key in value); +} diff --git a/src/lib/operations/find/find.ts b/src/lib/operations/find/find.ts index 3ecde1e5..0e2d0e9e 100755 --- a/src/lib/operations/find/find.ts +++ b/src/lib/operations/find/find.ts @@ -1,4 +1,5 @@ import { DMMF } from '@prisma/generator-helper'; +import { Prisma } from '@prisma/client'; import { FindArgs, GroupByFieldArg, Order, OrderedValue } from '../../types'; import { Delegate, DelegateProperties, Item } from '../../delegate'; @@ -19,6 +20,7 @@ export function findNextIncrement(properties: DelegateProperties, fieldName: str export function findOne(args: FindArgs, current: Delegate, delegates: Delegates) { const found = pipe( (items: Item[]) => items.filter((item) => where(args.where, current, delegates)(item)), + map(), order(args, current, delegates), connect(args, current, delegates), paginate(args.skip, args.take), @@ -259,9 +261,25 @@ function connect(args: FindArgs, current: Delegate, delegates: Delegates) { }; } +// Replace Prisma null types with JavaScript null +function map() { + return (items: Item[]) => + items.map((item) => { + return Object.fromEntries( + Object.entries(item).map(([key, value]) => { + if (value === Prisma.JsonNull || value === Prisma.DbNull) { + return [key, null]; + } + return [key, value]; + }), + ); + }); +} + export function findMany(args: FindArgs, current: Delegate, delegates: Delegates) { const found = pipe( (items: Item[]) => items.filter((item) => where(args.where, current, delegates)(item)), + map(), order(args, current, delegates), connect(args, current, delegates), paginate(args.skip, args.take), diff --git a/src/lib/operations/find/match.ts b/src/lib/operations/find/match.ts index 336f4fff..ad2de6c9 100755 --- a/src/lib/operations/find/match.ts +++ b/src/lib/operations/find/match.ts @@ -2,7 +2,7 @@ import { Prisma } from '@prisma/client'; import { DMMF } from '@prisma/client/runtime/library'; import { Delegate, Item } from '../../delegate'; -import { camelize, shallowCompare } from '../../helpers'; +import { camelize, shallowCompare, deepEqual, getJsonPath, isJsonFilter, objectContains } from '../../helpers'; import { Delegates } from '../../prismock'; import { FindWhereArgs } from '../../types'; @@ -120,6 +120,112 @@ export const matchMultiple = (item: Item, where: FindWhereArgs, current: Delegat return res.length > 0; } + if (info?.type === 'Json' && isJsonFilter(filter)) { + let match = true; + const value = 'path' in filter ? getJsonPath(val, filter.path) : val; + + // equals + if ('equals' in filter && match) { + if (filter.equals === Prisma.DbNull) { + match = value === Prisma.DbNull; + } else if (filter.equals === Prisma.AnyNull) { + match = value === Prisma.DbNull || value === Prisma.JsonNull; + } else { + if (value === Prisma.DbNull) { + match = false; + } else { + match = deepEqual(value, filter.equals); + } + } + } + + // not + if ('not' in filter && match) { + if (filter.not === Prisma.DbNull) { + match = value !== Prisma.DbNull; + } else { + if (value === Prisma.DbNull) { + match = false; + } else { + match = !deepEqual(value, filter.not); + } + } + } + + const mode = 'mode' in filter ? filter.mode : 'default'; + + // string_contains + if ('string_contains' in filter && typeof filter.string_contains === 'string' && 'path' in filter && match) { + if (mode === 'insentive') { + match = value?.toLowerCase().includes(filter.string_contains.toLowerCase()) ?? false; + } else { + match = value?.includes(filter.string_contains) ?? false; + } + } + + // string_starts_with + if ('string_starts_with' in filter && typeof filter.string_starts_with === 'string' && 'path' in filter && match) { + if (mode === 'insentive') { + match = value?.toLowerCase().startsWith(filter.string_starts_with.toLowerCase()) ?? false; + } else { + match = value?.startsWith(filter.string_starts_with) ?? false; + } + } + + // string_ends_with + if ('string_ends_with' in filter && typeof filter.string_ends_with === 'string' && 'path' in filter && match) { + if (mode === 'insentive') { + match = value?.toLowerCase().endsWith(filter.string_ends_with.toLowerCase()) ?? false; + } else { + match = value?.endsWith(filter.string_ends_with) ?? false; + } + } + + // array_contains + if ('array_contains' in filter && match) { + const contains = Array.isArray(filter.array_contains) ? filter.array_contains : [filter.array_contains]; + + match = + Array.isArray(value) && contains.every((entry: any) => value.some((item: any) => objectContains(item, entry))); + } + + // array_starts_with + if ('array_starts_with' in filter && match) { + const startsWith = Array.isArray(filter.array_starts_with) + ? filter.array_starts_with + : [filter.array_starts_with]; + + match = + Array.isArray(value) && + // We do a check by reference here, which seems to match the behaviour from Prisma + startsWith.every((entry: any, idx: number) => value[idx] === entry); + } + + // array_ends_with + if ('array_ends_with' in filter && match) { + if (Array.isArray(value)) { + value.reverse(); + } + const endsWith = Array.isArray(filter.array_ends_with) ? filter.array_ends_with : [filter.array_ends_with]; + endsWith.reverse(); + + match = + Array.isArray(value) && + endsWith.every( + // We do a check by reference here, which seems to match the behaviour from Prisma + (entry: any, idx: number) => value[idx] === entry, + ); + + // TODO: use Array.toReversed() after we update TS lib + if (Array.isArray(value)) { + value.reverse(); + } + endsWith.reverse(); + } + + return match; + } + const compositeIndex = current.model.uniqueIndexes.map((index) => index.name).includes(child) || current.model.primaryKey?.name === child || From 400d56a165adac54bcdc55612bd3bb88ab7e09e9 Mon Sep 17 00:00:00 2001 From: Alexandre Figueiredo Date: Tue, 5 May 2026 15:29:00 +0200 Subject: [PATCH 3/4] test: add json tests --- src/__tests__/client/client-custom.test.ts | 4 + src/__tests__/find/find.test.ts | 963 +++++++++++++++++++++ testing/index.ts | 140 ++- 3 files changed, 1106 insertions(+), 1 deletion(-) diff --git a/src/__tests__/client/client-custom.test.ts b/src/__tests__/client/client-custom.test.ts index d2b1ab4c..232ff8b1 100755 --- a/src/__tests__/client/client-custom.test.ts +++ b/src/__tests__/client/client-custom.test.ts @@ -27,6 +27,8 @@ describe('client (custom)', () => { profile: [], service: [], subscription: [], + comment: [], + element: [], }; if (provider !== 'mongodb') { @@ -63,6 +65,8 @@ describe('client (custom)', () => { profile: [], service: [], subscription: [], + comment: [], + element: [], }; if (provider !== 'mongodb') { diff --git a/src/__tests__/find/find.test.ts b/src/__tests__/find/find.test.ts index 9d8e02bf..0ee95826 100755 --- a/src/__tests__/find/find.test.ts +++ b/src/__tests__/find/find.test.ts @@ -4,6 +4,7 @@ import { version as clientVersion } from '@prisma/client/package.json'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { buildPost, + buildUser, formatEntries, formatEntry, generateId, @@ -11,6 +12,7 @@ import { resetDb, seededBlogs, seededUsers, + setupJsonTests, simulateSeed, } from '../../../testing'; import { PrismockClient, PrismockClientType } from '../../lib/client'; @@ -47,6 +49,967 @@ describe('find', () => { mockBlog = (await prismock.blog.findUnique({ where: { title: seededBlogs[0].title } }))!; }); + describe('JSON filters', () => { + const users: Array = []; + const cleanUpClients: Array<() => Promise> = []; + + beforeAll(async () => { + const userData = buildUser(4); + for (const client of [prisma, prismock]) { + const newUser = await client.user.create({ data: userData }); + const allUsers = await client.user.findMany({ orderBy: { id: 'asc' } }); + const clientUsers: number[] = []; + + for (const user of allUsers) { + const id = user.id; + + const parameters = { + address: { street: id }, + alias: [`User${id}.alias1`, `User${id}.alias2`, `User${id}.alias3`], + name: `User${id} Lastname`, + userNumber: id, + }; + + const updatedUser = await client.user.update({ + where: { id: user.id }, + data: { + parameters: id >= 3 ? parameters : [parameters], + }, + }); + + users.push(updatedUser); + clientUsers.push(user.id); + } + + const cleanUp = async () => { + await client.user.delete({ where: { id: newUser.id } }); + await client.user.updateMany({ + where: { + id: { + in: clientUsers, + }, + }, + data: { + parameters: {}, + }, + }); + }; + + cleanUpClients.push(cleanUp); + } + }); + + afterAll(async () => { + for (const cleanUp of cleanUpClients) { + await cleanUp(); + } + }); + + it('Should query JSON fields (equals)', async () => { + const realUser = await prisma.user.findFirst({ where: { parameters: { equals: users[1].parameters } } }); + const mockUser = await prismock.user.findFirst({ where: { parameters: { equals: users[1].parameters } } }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[1])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[1])); + }); + + it('Should query JSON fields (not)', async () => { + const realUser = await prisma.user.findFirst({ where: { parameters: { not: users[0].parameters } } }); + const mockUser = await prismock.user.findFirst({ where: { parameters: { not: users[0].parameters } } }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[1])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[1])); + }); + + it('Should query JSON fields (path)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['userNumber'], equals: 3 } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['userNumber'], equals: 3 } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should query JSON fields (string_contains)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_contains: 'User3' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_contains: 'User3' } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should fail to query JSON fields (string_contains)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_contains: 'FooBar' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_contains: 'FooBar' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + it('Should fail to query JSON fields (string_contains, default mode)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_contains: 'user3' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_contains: 'user3' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + // The current used version of Prisma does not have the mode option yet. + // TODO: Renable this test once Prisma is updated. + it.skip('Should fail to query JSON fields (string_contains, explicit default mode)', async () => { + const realUser = await prisma.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_contains: 'user3', mode: 'default' } }, + }); + const mockUser = await prismock.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_contains: 'user3', mode: 'default' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + // The current used version of Prisma does not have the mode option yet. + // TODO: Renable this test once Prisma is updated. + it.skip('Should query JSON fields (string_contains, insensitive mode)', async () => { + const realUser = await prisma.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_contains: 'user3', mode: 'insensitive' } }, + }); + const mockUser = await prismock.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_contains: 'user3', mode: 'insensitive' } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should query JSON fields (string_starts_with)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_starts_with: 'User3' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_starts_with: 'User3' } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should fail to query JSON fields (string_starts_with)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_starts_with: 'Lastname' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_starts_with: 'Lastname' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + it('Should fail to query JSON fields (string_starts_with, default mode)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_starts_with: 'user3' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_starts_with: 'user3' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + // The current used version of Prisma does not have the mode option yet. + // TODO: Renable this test once Prisma is updated. + it.skip('Should fail to query JSON fields (string_starts_with, explicit default mode)', async () => { + const realUser = await prisma.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_starts_with: 'user3', mode: 'default' } }, + }); + const mockUser = await prismock.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_starts_with: 'user3', mode: 'default' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + // The current used version of Prisma does not have the mode option yet. + // TODO: Renable this test once Prisma is updated. + it.skip('Should query JSON fields (string_starts_with, insensitive mode)', async () => { + const realUser = await prisma.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_starts_with: 'user3', mode: 'insensitive' } }, + }); + const mockUser = await prismock.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_starts_with: 'user3', mode: 'insensitive' } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should query JSON fields (string_ends_with)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_ends_with: '3 Lastname' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_ends_with: '3 Lastname' } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should fail to query JSON fields (string_ends_with)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_ends_with: 'FooBar' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_ends_with: 'FooBar' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + it('Should fail to query JSON fields (string_ends_with, default mode)', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['name'], string_ends_with: '3 lastname' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['name'], string_ends_with: '3 lastname' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + // The current used version of Prisma does not have the mode option yet. + // TODO: Renable this test once Prisma is updated. + it.skip('Should fail to query JSON fields (string_ends_with, explicit default mode)', async () => { + const realUser = await prisma.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_ends_with: '3 lastname', mode: 'default' } }, + }); + const mockUser = await prismock.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_ends_with: '3 lastname', mode: 'default' } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + // The current used version of Prisma does not have the mode option yet. + // TODO: Renable this test once Prisma is updated. + it.skip('Should query JSON fields (string_ends_with, insensitive mode)', async () => { + const realUser = await prisma.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_ends_with: '3 lastname', mode: 'insensitive' } }, + }); + const mockUser = await prismock.user.findFirst({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + where: { parameters: { path: ['name'], string_ends_with: '3 lastname', mode: 'insensitive' } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should query JSON fields (array_contains)', async () => { + const realUser = await prisma.user.findFirst({ where: { parameters: { array_contains: [{ userNumber: 2 }] } } }); + const mockUser = await prismock.user.findFirst({ where: { parameters: { array_contains: [{ userNumber: 2 }] } } }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[1])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[1])); + }); + + it('Should fail to query JSON fields (array_starts_with object)', async () => { + const param = { address: { street: 2 }, name: 'User2 Lastname', userNumber: 2 }; + const realUser = await prisma.user.findFirst({ + where: { parameters: { array_starts_with: [param] } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { array_starts_with: [param] } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + it('Should fail to query JSON fields (array_starts_with) partial object', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { array_starts_with: [{ userNumber: 2 }] } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { array_starts_with: [{ userNumber: 2 }] } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + it('Should query JSON fields (array_starts_with) primitives', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['alias'], array_starts_with: 'User3.alias1' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['alias'], array_starts_with: 'User3.alias1' } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should fail to query JSON fields (array_ends_with object)', async () => { + const param = { address: { street: 2 }, name: 'User2 Lastname', userNumber: 2 }; + + const realUser = await prisma.user.findFirst({ + where: { parameters: { array_ends_with: [param] } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { array_ends_with: [param] } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + it('Should fail to query JSON fields (array_ends_with) partial object', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { array_ends_with: [{ userNumber: 2 }] } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { array_ends_with: [{ userNumber: 2 }] } }, + }); + + expect(formatEntry(realUser)).toEqual(null); + expect(formatEntry(mockUser)).toEqual(null); + }); + + it('Should query JSON fields (array_ends_with) primitives', async () => { + const realUser = await prisma.user.findFirst({ + where: { parameters: { path: ['alias'], array_ends_with: 'User3.alias3' } }, + }); + const mockUser = await prismock.user.findFirst({ + where: { parameters: { path: ['alias'], array_ends_with: 'User3.alias3' } }, + }); + + expect(formatEntry(realUser)).toEqual(formatEntry(users[2])); + expect(formatEntry(mockUser)).toEqual(formatEntry(users[2])); + }); + + it('Should handle multiple values (array_contains)', async () => { + const realUser1 = await prisma.user.findFirst({ + where: { parameters: { array_contains: [{ userNumber: 3 }, { name: 'User3 Lastname' }] } }, + }); + const mockUser1 = await prismock.user.findFirst({ + where: { parameters: { array_contains: [{ userNumber: 3 }, { name: 'User3 Lastname' }] } }, + }); + + const realUser2 = await prisma.user.findFirst({ + where: { parameters: { array_contains: [{ userNumber: 3 }, { name: 'User2 Lastname' }] } }, + }); + const mockUser2 = await prismock.user.findFirst({ + where: { parameters: { array_contains: [{ userNumber: 3 }, { name: 'User2 Lastname' }] } }, + }); + + const realUser3 = await prisma.user.findMany({ + where: { parameters: { array_contains: [{ userNumber: 1 }, { name: 'User2 Lastname' }, { userNumber: 4 }] } }, + }); + const mockUser3 = await prismock.user.findMany({ + where: { parameters: { array_contains: [{ userNumber: 1 }, { name: 'User2 Lastname' }, { userNumber: 4 }] } }, + }); + + const realUser4 = await prisma.user.findMany({ + where: { + parameters: { array_contains: [{ userNumber: 1 }, { name: 'User2 Lastname' }, { address: { street: 4 } }] }, + }, + }); + const mockUser4 = await prismock.user.findMany({ + where: { + parameters: { array_contains: [{ userNumber: 1 }, { name: 'User2 Lastname' }, { address: { street: 4 } }] }, + }, + }); + + const realUser5 = await prisma.user.findMany({ + where: { parameters: { array_contains: [{ userNumber: 1 }, { userNumber: 2 }] } }, + }); + const mockUser5 = await prismock.user.findMany({ + where: { parameters: { array_contains: [{ userNumber: 1 }, { userNumber: 2 }] } }, + }); + + expect(realUser1).toEqual(null); + expect(realUser2).toEqual(null); + expect(realUser3).toEqual([]); + expect(realUser4).toEqual([]); + expect(realUser5).toEqual([]); + + expect(mockUser1).toEqual(realUser1); + expect(mockUser2).toEqual(realUser2); + expect(mockUser3).toEqual(realUser3); + expect(mockUser4).toEqual(realUser4); + expect(mockUser5).toEqual(realUser5); + }); + }); + + // Tests adapted from: + // https://github.com/demonsters/prisma-mock/blob/2faf33862e4147e4c262d6e37235837a5dc895a9/__tests__/json.test.ts + describe('Prisma-mock', () => { + let createElements: Array<{ + userId: number; + value: string; + e_id: number; + json: Prisma.JsonValue; + }> = []; + let cleanUp: () => Promise; + + beforeAll(async () => { + [createElements, cleanUp] = await setupJsonTests([prisma, prismock]); + }); + + afterAll(async () => { + await cleanUp(); + }); + + it('simple use case', () => { + expect(createElements[0].json).toEqual([ + { + name: 'Bob the dog', + }, + { + name: 'Claudine the cat', + }, + ]); + expect(createElements[1].json).toEqual([ + { + name: 'Bob the dog', + }, + { + name: 'Claudine the cat', + }, + ]); + }); + + describe('Filter on exact field value', () => { + test('equals', async () => { + const json = [{ name: 'Bob the dog' }, { name: 'Claudine the cat' }]; + + const realGetUsers = await prisma.element.findMany({ + where: { + json: { + equals: json, + }, + }, + }); + const mockGetUsers = await prismock.element.findMany({ + where: { + json: { + equals: json, + }, + }, + }); + expect(realGetUsers).toEqual([ + { + e_id: 5, + json: [ + { + name: 'Bob the dog', + }, + { + name: 'Claudine the cat', + }, + ], + userId: 5, + value: '5', + }, + ]); + expect(mockGetUsers).toEqual(realGetUsers); + }); + + test('not', async () => { + const json = [{ name: 'Bob the dog' }, { name: 'Claudine the cat' }]; + + const realGetUsers = await prisma.element.findMany({ + where: { + json: { + not: json, + }, + }, + }); + const realAll = await prisma.element.findMany({ + where: { + e_id: { + not: createElements[0].e_id, + }, + json: { + not: Prisma.DbNull, + }, + }, + }); + + const mockGetUsers = await prismock.element.findMany({ + where: { + json: { + not: json, + }, + }, + }); + const mockAll = await prismock.element.findMany({ + where: { + e_id: { + not: createElements[1].e_id, + }, + json: { + not: Prisma.DbNull, + }, + }, + }); + + expect(realGetUsers).toEqual(realAll); + expect(mockGetUsers).toEqual(mockAll); + expect(mockGetUsers).toEqual(realGetUsers); + }); + }); + + describe('Filter on nested object property', () => { + test('path', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + path: ['pet2', 'petName'], + equals: 'Sunny', + }, + }, + }); + + const mockElement = await prismock.element.findMany({ + where: { + json: { + path: ['pet2', 'petName'], + equals: 'Sunny', + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 4, + json: { + pet1: { + petName: 'Claudine', + petType: 'House cat', + }, + pet2: { + features: { + eyeColor: 'Brown', + furColor: 'White and black', + }, + petName: 'Sunny', + petType: 'Gerbil', + }, + }, + userId: 5, + value: '4', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + + test('string_contains', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + path: ['pet1', 'petType'], + string_contains: 'cat', + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + path: ['pet1', 'petType'], + string_contains: 'cat', + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 4, + json: { + pet1: { + petName: 'Claudine', + petType: 'House cat', + }, + pet2: { + features: { + eyeColor: 'Brown', + furColor: 'White and black', + }, + petName: 'Sunny', + petType: 'Gerbil', + }, + }, + userId: 5, + value: '4', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + + test('string_starts_with', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + path: ['pet1', 'petType'], + string_starts_with: 'House', + }, + }, + }); + + const mockElement = await prismock.element.findMany({ + where: { + json: { + path: ['pet1', 'petType'], + string_starts_with: 'House', + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 4, + json: { + pet1: { + petName: 'Claudine', + petType: 'House cat', + }, + pet2: { + features: { + eyeColor: 'Brown', + furColor: 'White and black', + }, + petName: 'Sunny', + petType: 'Gerbil', + }, + }, + userId: 5, + value: '4', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + + test('string_ends_with', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + path: ['pet1', 'petType'], + string_ends_with: 'cat', + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + path: ['pet1', 'petType'], + string_ends_with: 'cat', + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 4, + json: { + pet1: { + petName: 'Claudine', + petType: 'House cat', + }, + pet2: { + features: { + eyeColor: 'Brown', + furColor: 'White and black', + }, + petName: 'Sunny', + petType: 'Gerbil', + }, + }, + userId: 5, + value: '4', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + }); + + describe('Filtering on an array value', () => { + test('array_contains', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + array_contains: [ + { + name: 'Bob the dog', + }, + ], + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + array_contains: [ + { + name: 'Bob the dog', + }, + ], + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 5, + json: [ + { + name: 'Bob the dog', + }, + { + name: 'Claudine the cat', + }, + ], + userId: 5, + value: '5', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + }); + + describe('Filtering on nested array value', () => { + test(')ne', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + path: ['cats', 'fostering'], + array_contains: ['Fido'], + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + path: ['cats', 'fostering'], + array_contains: ['Fido'], + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 6, + json: { + cats: { + fostering: ['Fido'], + owned: ['Bob', 'Sunny'], + }, + dogs: { + fostering: ['Prince', 'Empress'], + owned: ['Ella'], + }, + }, + userId: 5, + value: '6', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + + test('Two with no match', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + path: ['cats', 'fostering'], + array_contains: ['Fido', 'Bob'], + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + path: ['cats', 'fostering'], + array_contains: ['Fido', 'Bob'], + }, + }, + }); + expect(realElement).toEqual([]); + expect(mockElement).toEqual(realElement); + }); + + test('Two with match', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + path: ['cats', 'fostering'], + array_contains: ['Bill', 'Bob'], + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + path: ['cats', 'fostering'], + array_contains: ['Bill', 'Bob'], + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 8, + json: { + cats: { + fostering: ['Bob', 'Bill'], + owned: ['John'], + }, + }, + userId: 5, + value: '8', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + }); + + describe('Filtering on object key value inside array (MySQL only)', () => { + // test.skip('array_contains', async () => { + // const element = await prisma.element.findMany({ + // where: { + // json: { + // path: '$[*].name', + // array_contains: 'Bob the dog', + // }, + // }, + // }); + // expect(element).toEqual([]); + // }); + }); + + describe('Using null Values', () => { + test('JsonNull', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + equals: Prisma.JsonNull, + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + equals: Prisma.JsonNull, + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 9, + json: null, + userId: 5, + value: '9', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + + test('DbNull', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + equals: Prisma.DbNull, + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + equals: Prisma.DbNull, + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 10, + json: null, + userId: 5, + value: '10', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + + test('AnyNull', async () => { + const realElement = await prisma.element.findMany({ + where: { + json: { + equals: Prisma.AnyNull, + }, + }, + }); + const mockElement = await prismock.element.findMany({ + where: { + json: { + equals: Prisma.AnyNull, + }, + }, + }); + expect(realElement).toEqual([ + { + e_id: 9, + json: null, + userId: 5, + value: '9', + }, + { + e_id: 10, + json: null, + userId: 5, + value: '10', + }, + ]); + expect(mockElement).toEqual(realElement); + }); + }); + }); + describe('findFirst', () => { it('Should return first corresponding item', async () => { const realUser = (await prisma.user.findFirst({ diff --git a/testing/index.ts b/testing/index.ts index 27882855..28c59320 100755 --- a/testing/index.ts +++ b/testing/index.ts @@ -1,6 +1,6 @@ import { exec } from 'child_process'; -import { Blog, Post, PrismaClient, Reaction, Role, Service, Subscription, User } from '@prisma/client'; +import { Blog, Post, Prisma, PrismaClient, Reaction, Role, Service, Subscription, User } from '@prisma/client'; import dotenv from 'dotenv'; import { createId } from '@paralleldrive/cuid2'; @@ -115,3 +115,141 @@ export function formatEntries(entries: Array>) { export function generateId(baseId: number) { return baseId; } + +export async function setupJsonTests(clients: T[]) { + const objs: Array<{ + userId: number; + value: string; + e_id: number; + json: Prisma.JsonValue; + }> = []; + + const json = [{ name: 'Bob the dog' }, { name: 'Claudine the cat' }] as Prisma.JsonArray; + const usersMap = new WeakMap(); + + for (const client of clients) { + const user = await client.user.create({ + data: { + id: 5, + email: 'user@json.com', + password: 'jsonuser', + }, + }); + usersMap.set(client, user.id); + + await client.element.create({ + data: { + json: 'A string test', + value: '1', + userId: user.id, + }, + }); + + await client.element.create({ + data: { + json: 123, + value: '2', + userId: user.id, + }, + }); + + await client.element.create({ + data: { + json: { + object: 'test', + }, + value: '3', + userId: user.id, + }, + }); + + await client.element.create({ + data: { + json: { + pet1: { + petName: 'Claudine', + petType: 'House cat', + }, + pet2: { + petName: 'Sunny', + petType: 'Gerbil', + features: { + eyeColor: 'Brown', + furColor: 'White and black', + }, + }, + }, + value: '4', + userId: user.id, + }, + }); + + const obj = await client.element.create({ + data: { + json, + value: '5', + userId: user.id, + }, + }); + + objs.push(obj); + + await client.element.create({ + data: { + json: { + cats: { owned: ['Bob', 'Sunny'], fostering: ['Fido'] }, + dogs: { owned: ['Ella'], fostering: ['Prince', 'Empress'] }, + }, + value: '6', + userId: user.id, + }, + }); + + await client.element.create({ + data: { + json: { + cats: { owned: ['John'], fostering: ['Bob'] }, + }, + value: '7', + userId: user.id, + }, + }); + + await client.element.create({ + data: { + json: { + cats: { owned: ['John'], fostering: ['Bob', 'Bill'] }, + }, + value: '8', + userId: user.id, + }, + }); + + await client.element.create({ + data: { + json: Prisma.JsonNull, + value: '9', + userId: user.id, + }, + }); + + await client.element.create({ + data: { + json: Prisma.DbNull, + value: '10', + userId: user.id, + }, + }); + } + + return [ + objs, + async () => { + for (const client of clients) { + await client.element.deleteMany(); + const userId = usersMap.get(client); + await client.user.delete({ where: { id: userId } }); + } + }, + ] as const; +} From 9100bea06212eb2835bed5440885a0c4b4b26501 Mon Sep 17 00:00:00 2001 From: Alexandre Figueiredo Date: Tue, 5 May 2026 15:32:55 +0200 Subject: [PATCH 4/4] docs: update README --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6e2bbb63..ab3184b2 100644 --- a/README.md +++ b/README.md @@ -206,13 +206,13 @@ prismock.reset(); // State of prismock back to its original | Feature | State | | ------------------- | ----- | -| path | ⛔ | -| string_contains | ⛔ | -| string_starts_withn | ⛔ | -| string_ends_with | ⛔ | -| array_contains | ⛔ | -| array_starts_with | ⛔ | -| array_ends_with | ⛔ | +| path | ✔ | +| string_contains | ✔ | +| string_starts_withn | ✔ | +| string_ends_with | ✔ | +| array_contains | ✔ | +| array_starts_with | ✔ | +| array_ends_with | ✔ | ## Attributes