From a68a7860a708c666ae0df9abce38d7334094b758 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:48:01 +0100 Subject: [PATCH] fix(wabe): recursion on contains --- packages/wabe-mongodb/src/index.test.ts | 51 ++++++++++++++++ packages/wabe-mongodb/src/index.ts | 19 +++++- packages/wabe-postgres/src/index.test.ts | 34 +++++++++++ packages/wabe-postgres/src/index.ts | 20 ++++++- packages/wabe/src/security.test.ts | 74 ++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 5 deletions(-) diff --git a/packages/wabe-mongodb/src/index.test.ts b/packages/wabe-mongodb/src/index.test.ts index 0e173455..6b6e8843 100644 --- a/packages/wabe-mongodb/src/index.test.ts +++ b/packages/wabe-mongodb/src/index.test.ts @@ -418,6 +418,40 @@ describe('Mongo adapter', () => { expect(res[0]?.object?.array).toEqual([{ string: 'user2' }]) }) + it('should correctly filter with nested notContains and equalTo without recursion overwrite', async () => { + await mongoAdapter.createObject({ + className: 'Test', + data: { + object: { array: [{ string: 'user1' }] }, + }, + context, + }) + + await mongoAdapter.createObject({ + className: 'Test', + data: { + object: { array: [{ string: 'user2' }] }, + }, + context, + }) + + const res = await mongoAdapter.getObjects({ + className: 'Test', + context, + where: { + object: { + // @ts-expect-error + array: { + notContains: { string: { equalTo: 'user1' } }, + }, + }, + }, + }) + + expect(res.length).toBe(1) + expect(res[0]?.object?.array).toEqual([{ string: 'user2' }]) + }) + it('should retry on connection error', async () => { const spyMongoClientConnect = spyOn(mongoAdapter.client, 'connect').mockImplementationOnce( () => { @@ -1938,6 +1972,23 @@ describe('Mongo adapter', () => { expect(where).toEqual({}) }) + it('should build nested notContains with equalTo for MongoDB $elemMatch', () => { + const where = buildMongoWhereQuery({ + data: { + // @ts-expect-error + array: { + notContains: { string: { equalTo: 'user1' } }, + }, + }, + }) + + expect(where).toEqual({ + 'data.array': { + $not: { $elemMatch: { string: 'user1' } }, + }, + }) + }) + it('should request sub object in object', async () => { await mongoAdapter.createObject({ className: 'User', diff --git a/packages/wabe-mongodb/src/index.ts b/packages/wabe-mongodb/src/index.ts index dbfb48e9..d191a300 100644 --- a/packages/wabe-mongodb/src/index.ts +++ b/packages/wabe-mongodb/src/index.ts @@ -19,6 +19,10 @@ import type { SchemaInterface, } from 'wabe' +/** Use built MongoDB query if non-empty, otherwise fallback to original (e.g. direct value { string: 'user1' }). */ +const builtOrOriginal = (built: Record, original: unknown) => + Object.keys(built).length ? built : original + export const buildMongoOrderQuery = < T extends WabeTypes, K extends keyof T['types'], @@ -63,11 +67,20 @@ export const buildMongoWhereQuery = ), + value.notContains, + ), + }, + } : { $nin: Array.isArray(value.notContains) ? value.notContains : [value.notContains] } + return acc + } if (value?.exists === true) acc[keyToWrite] = { $exists: true, $ne: null } if (value?.exists === false) acc[keyToWrite] = { $eq: null } @@ -154,6 +167,8 @@ export const buildMongoWhereQuery = ) const entries = Object.entries(where) diff --git a/packages/wabe-postgres/src/index.test.ts b/packages/wabe-postgres/src/index.test.ts index b9ce9bea..067ea796 100644 --- a/packages/wabe-postgres/src/index.test.ts +++ b/packages/wabe-postgres/src/index.test.ts @@ -427,6 +427,40 @@ describe('Postgres adapter', () => { expect(res[0]?.object?.array).toEqual([{ string: 'user2' }]) }) + it('should correctly filter with nested notContains and equalTo without recursion overwrite', async () => { + await postgresAdapter.createObject({ + className: 'Test', + data: { + object: { array: [{ string: 'user1' }] }, + }, + context, + }) + + await postgresAdapter.createObject({ + className: 'Test', + data: { + object: { array: [{ string: 'user2' }] }, + }, + context, + }) + + const res = await postgresAdapter.getObjects({ + className: 'Test', + context, + where: { + object: { + // @ts-expect-error + array: { + notContains: { string: { equalTo: 'user1' } }, + }, + }, + }, + }) + + expect(res.length).toBe(1) + expect(res[0]?.object?.array).toEqual([{ string: 'user2' }]) + }) + it('should create class', async () => { const client = await postgresAdapter.pool.connect() diff --git a/packages/wabe-postgres/src/index.ts b/packages/wabe-postgres/src/index.ts index a5ab1f53..78286967 100644 --- a/packages/wabe-postgres/src/index.ts +++ b/packages/wabe-postgres/src/index.ts @@ -52,6 +52,19 @@ const getSQLColumnCreateTableFromType = (type: TypeField } } +/** Resolve where operators (e.g. { string: { equalTo: 'user1' } }) to plain values for JSON. */ +const resolveWhereToPlain = (obj: unknown): unknown => { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [ + key, + value && typeof value === 'object' && !Array.isArray(value) && 'equalTo' in value + ? (value as { equalTo: unknown }).equalTo + : resolveWhereToPlain(value), + ]), + ) +} + export const buildPostgresOrderQuery = < T extends WabeTypes, K extends keyof T['types'], @@ -207,10 +220,11 @@ export const buildPostgresWhereQueryAndValues = $${acc.paramIndex})`) + const toJson = (v: unknown) => (Array.isArray(v) ? JSON.stringify(v) : JSON.stringify([v])) acc.values.push( - Array.isArray(value.notContains) - ? JSON.stringify(value.notContains) - : JSON.stringify([value.notContains]), + typeof value.notContains === 'object' && !Array.isArray(value.notContains) + ? toJson(resolveWhereToPlain(value.notContains)) + : toJson(value.notContains), ) acc.paramIndex++ return acc diff --git a/packages/wabe/src/security.test.ts b/packages/wabe/src/security.test.ts index b3b232d4..1decc53e 100644 --- a/packages/wabe/src/security.test.ts +++ b/packages/wabe/src/security.test.ts @@ -2014,6 +2014,80 @@ describe('Security tests', () => { await closeTests(wabe) }) + it('should correctly filter with nested notContains without recursion overwrite (MongoDB buildMongoWhereQuery)', async () => { + const setup = await setupTests([ + { + name: 'TestRecursion', + fields: { + data: { + type: 'Object', + object: { + name: 'TestRecursionData', + fields: { + array: { + type: 'Array', + typeValue: 'Object', + object: { + name: 'TestRecursionArrayItem', + fields: { + string: { type: 'String' }, + }, + }, + }, + }, + }, + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + }, + }, + ]) + + const wabe = setup.wabe + const port = setup.port + const rootClient = getGraphqlClient(port) + + await rootClient.request(gql` + mutation create1 { + createTestRecursion(input: { fields: { data: { array: [{ string: "user1" }] } } }) { + testRecursion { + id + } + } + } + `) + + await rootClient.request(gql` + mutation create2 { + createTestRecursion(input: { fields: { data: { array: [{ string: "user2" }] } } }) { + testRecursion { + id + } + } + } + `) + + const res = await wabe.controllers.database.getObjects({ + className: 'TestRecursion' as any, + context: { isRoot: true, wabe } as any, + where: { + data: { + array: { + notContains: { string: { equalTo: 'user1' } }, + }, + }, + } as any, + select: { data: true } as any, + }) + + expect(res.length).toBe(1) + expect((res[0] as any)?.data?.array).toEqual([{ string: 'user2' }]) + + await closeTests(wabe) + }) + it('should not authorize an user to write (delete) an object when the user has not access on write to the object (ACL)', async () => { const setup = await setupTests([ {