diff --git a/packages/wabe/src/database/DatabaseController.ts b/packages/wabe/src/database/DatabaseController.ts index be4cc949..a2073728 100644 --- a/packages/wabe/src/database/DatabaseController.ts +++ b/packages/wabe/src/database/DatabaseController.ts @@ -147,6 +147,76 @@ export class DatabaseController { } } + _buildHookReadSelect({ + className, + context, + userSelect, + selectWithoutPointers, + }: { + className: keyof T['types'] + context: WabeContext + userSelect: Select + selectWithoutPointers: Select + }): Select { + const selectedVirtualFields = Object.keys(this._getVirtualFieldsForClass(className, context)) + .filter((fieldName) => !!selectWithoutPointers[fieldName]) + .map((fieldName) => [fieldName, true]) + + return { + ...userSelect, + ...Object.fromEntries(selectedVirtualFields), + } + } + + _initializeReadHook({ + className, + context, + userSelect, + selectWithoutPointers, + _skipHooks, + }: { + className: K + context: WabeContext + userSelect: Select + selectWithoutPointers: Select + _skipHooks?: boolean + }) { + if (_skipHooks) return undefined + + return initializeHook({ + className, + context, + select: this._buildHookReadSelect({ + className, + context, + userSelect, + selectWithoutPointers, + }), + objectLoader: this._loadObjectForHooks(className, context), + objectsLoader: this._loadObjectsForHooks(className, context), + }) + } + + _buildSelectWithPointers({ + adapterSelect, + pointers, + }: { + adapterSelect: Select + pointers: Record + }) { + return Object.keys(pointers).reduce( + (acc, fieldName) => { + acc[fieldName] = true + return acc + }, + { ...adapterSelect }, + ) + } + + _isEmptySelect(select?: Record): boolean { + return !!select && Object.keys(select).length === 0 + } + _projectObjectForUserSelect({ object, select, @@ -388,69 +458,58 @@ export class DatabaseController { const roleId = context.user?.role?.id const userId = context.user?.id - // If we have an user we good right we return - // If we don't have user we check role - // If the role is good we return + const aclNullCondition = { + acl: { equalTo: null }, + } + const aclUserCondition = userId + ? { + acl: { + users: { + contains: { + userId, + [operation]: true, + }, + }, + }, + } + : undefined + const aclRoleCondition = roleId + ? { + AND: [ + { + acl: { + users: { + notContains: { + userId, + }, + }, + }, + }, + { + acl: { + roles: { + contains: { + roleId, + [operation]: true, + }, + }, + }, + }, + ], + } + : undefined + + const aclForUnauthenticated = !userId ? aclNullCondition : undefined + const aclForUserOrRole = + userId || roleId + ? { + OR: [aclNullCondition, aclUserCondition, aclRoleCondition].filter(notEmpty), + } + : undefined - // @ts-expect-error return { - AND: [ - { ...where }, - // If the user is not connected we need to have a null acl - !userId - ? { - acl: { equalTo: null }, - } - : undefined, - // If we have user or role we need to check the acl - userId || roleId - ? { - OR: [ - { - acl: { equalTo: null }, - }, - userId - ? { - acl: { - users: { - contains: { - userId, - [operation]: true, - }, - }, - }, - } - : undefined, - roleId - ? { - AND: [ - { - acl: { - users: { - notContains: { - userId, - }, - }, - }, - }, - { - acl: { - roles: { - contains: { - roleId, - [operation]: true, - }, - }, - }, - }, - ], - } - : undefined, - ].filter(notEmpty), - } - : undefined, - ].filter(notEmpty), - } + AND: [{ ...where }, aclForUnauthenticated, aclForUserOrRole].filter(notEmpty), + } as WhereType } /** @@ -542,6 +601,87 @@ export class DatabaseController { return res } + _getRelationSelectWithoutTotalCount(currentSelect?: Select): Select { + const selectWithoutTotalCount = currentSelect + ? Object.entries(currentSelect).reduce((acc, [key, value]) => { + if (key === 'totalCount') return acc + return { + ...acc, + [key]: value, + } + }, {}) + : undefined + + return selectWithoutTotalCount && Object.keys(selectWithoutTotalCount).length > 0 + ? (selectWithoutTotalCount as Select) + : { id: true } + } + + async _resolvePointerField({ + currentClassName, + object, + pointerField, + currentSelect, + context, + _skipHooks, + }: { + currentClassName: string + object: Record + pointerField: string + currentSelect?: Select + context: WabeContext + _skipHooks?: boolean + }) { + if (!object[pointerField]) return null + + return this.getObject({ + className: currentClassName, + id: object[pointerField], + context, + // @ts-expect-error + select: currentSelect, + _skipHooks, + }) + } + + async _resolveRelationField({ + currentClassName, + object, + pointerField, + currentSelect, + context, + _skipHooks, + }: { + currentClassName: string + object: Record + pointerField: string + currentSelect?: Select + context: WabeContext + _skipHooks?: boolean + }) { + const relationIds = object[pointerField] + if (!relationIds) return undefined + + const selectWithoutTotalCount = this._getRelationSelectWithoutTotalCount(currentSelect) + const relationObjects = await this.getObjects({ + className: currentClassName, + select: selectWithoutTotalCount as any, + // @ts-expect-error + where: { id: { in: relationIds } }, + context, + _skipHooks, + }) + + if (!context.isGraphQLCall) return relationObjects + + return { + totalCount: relationObjects.length, + edges: relationObjects.map((object: any) => ({ + node: object, + })), + } + } + _getFinalObjectWithPointerAndRelation({ pointers, context, @@ -549,18 +689,20 @@ export class DatabaseController { object, _skipHooks, }: { - originClassName: string + originClassName: keyof T['types'] pointers: Record context: WabeContext - object: Record + object: Record | null | undefined _skipHooks?: boolean }) { + if (!object) return Promise.resolve({}) + return Object.entries(pointers).reduce( async (acc, [pointerField, { className: currentClassName, select: currentSelect }]) => { const accObject = await acc const isPointer = this._isFieldOfType( - originClassName, + String(originClassName), pointerField, 'Pointer', context, @@ -568,76 +710,44 @@ export class DatabaseController { ) if (isPointer) { - if (!object[pointerField]) - return { - ...accObject, - [pointerField]: null, - } - - const objectOfPointerClass = await this.getObject({ - className: currentClassName, - id: object[pointerField], - context, - // @ts-expect-error - select: currentSelect, - _skipHooks, - }) - return { ...accObject, - [pointerField]: objectOfPointerClass, + [pointerField]: await this._resolvePointerField({ + currentClassName, + object, + pointerField, + currentSelect, + context, + _skipHooks, + }), } } const isRelation = this._isFieldOfType( - originClassName, + String(originClassName), pointerField, 'Relation', context, currentClassName, ) - if (isRelation && object[pointerField]) { - const computedSelectWithoutTotalCount = currentSelect - ? Object.entries(currentSelect).reduce((acc2, [key, value]) => { - if (key === 'totalCount') return acc2 - - return { - ...acc2, - [key]: value, - } - }, {}) - : undefined - - const selectWithoutTotalCount = - computedSelectWithoutTotalCount && - Object.keys(computedSelectWithoutTotalCount).length > 0 - ? computedSelectWithoutTotalCount - : { id: true } - - const relationObjects = await this.getObjects({ - className: currentClassName, - select: selectWithoutTotalCount, - // @ts-expect-error - where: { id: { in: object[pointerField] } }, - context, - _skipHooks, - }) + if (!isRelation) return accObject - return { - ...accObject, - [pointerField]: context.isGraphQLCall - ? { - totalCount: relationObjects.length, - edges: relationObjects.map((object: any) => ({ - node: object, - })), - } - : relationObjects, - } - } + const relationValue = await this._resolveRelationField({ + currentClassName, + object, + pointerField, + currentSelect, + context, + _skipHooks, + }) - return accObject + if (relationValue === undefined) return accObject + + return { + ...accObject, + [pointerField]: relationValue, + } }, Promise.resolve({} as Record), ) @@ -714,22 +824,13 @@ export class DatabaseController { selectWithoutPointers, }) - const hook = !_skipHooks - ? initializeHook({ - className, - context, - select: { - ...userSelect, - ...Object.fromEntries( - Object.keys(this._getVirtualFieldsForClass(className, context)) - .filter((fieldName) => !!selectWithoutPointers[fieldName]) - .map((fieldName) => [fieldName, true]), - ), - }, - objectLoader: this._loadObjectForHooks(className, context), - objectsLoader: this._loadObjectsForHooks(className, context), - }) - : undefined + const hook = this._initializeReadHook({ + className, + context, + userSelect, + selectWithoutPointers, + _skipHooks, + }) await hook?.runOnSingleObject({ operationType: OperationType.BeforeRead, @@ -738,11 +839,10 @@ export class DatabaseController { const whereWithACLCondition = this._buildWhereWithACL(where || {}, context, 'read') - const selectWithPointersAndRelationsToGetId = Object.keys(pointers).reduce((acc, fieldName) => { - acc[fieldName] = true - - return acc - }, adapterSelect) + const selectWithPointersAndRelationsToGetId = this._buildSelectWithPointers({ + adapterSelect, + pointers, + }) const objectToReturn = await this.adapter.getObject({ className, @@ -757,10 +857,8 @@ export class DatabaseController { ...objectToReturn, ...(await this._getFinalObjectWithPointerAndRelation({ context, - // @ts-expect-error originClassName: className, pointers, - // @ts-expect-error object: objectToReturn, _skipHooks, })), @@ -814,28 +912,18 @@ export class DatabaseController { const whereWithACLCondition = this._buildWhereWithACL(whereWithPointer || {}, context, 'read') - const selectWithPointersAndRelationsToGetId = Object.keys(pointers).reduce((acc, fieldName) => { - acc[fieldName] = true - - return acc - }, adapterSelect) + const selectWithPointersAndRelationsToGetId = this._buildSelectWithPointers({ + adapterSelect, + pointers, + }) - const hook = !_skipHooks - ? initializeHook({ - className, - select: { - ...userSelect, - ...Object.fromEntries( - Object.keys(this._getVirtualFieldsForClass(className, context)) - .filter((fieldName) => !!selectWithoutPointers[fieldName]) - .map((fieldName) => [fieldName, true]), - ), - }, - context, - objectLoader: this._loadObjectForHooks(className, context), - objectsLoader: this._loadObjectsForHooks(className, context), - }) - : undefined + const hook = this._initializeReadHook({ + className, + context, + userSelect, + selectWithoutPointers, + _skipHooks, + }) await hook?.runOnMultipleObjects({ operationType: OperationType.BeforeRead, @@ -858,10 +946,8 @@ export class DatabaseController { return { ...object, ...(await this._getFinalObjectWithPointerAndRelation({ - // @ts-expect-error object, context, - // @ts-expect-error originClassName: className, pointers, _skipHooks, @@ -926,7 +1012,7 @@ export class DatabaseController { const res = result as { id: string } - if (select && Object.keys(select).length === 0) return null + if (this._isEmptySelect(select as Record)) return null const selectWithoutPrivateFields = select ? selectFieldsWithoutPrivateFields(select) : undefined @@ -1008,7 +1094,7 @@ export class DatabaseController { ), ) - if (select && Object.keys(select).length === 0) return [] + if (this._isEmptySelect(select as Record)) return [] return this.getObjects({ className, @@ -1081,7 +1167,7 @@ export class DatabaseController { }, }) - if (select && Object.keys(select).length === 0) return null + if (this._isEmptySelect(select as Record)) return null return this.getObject({ className, @@ -1155,7 +1241,7 @@ export class DatabaseController { originalObjects: resultsAfterBeforeUpdate?.objects || [], }) - if (select && Object.keys(select).length === 0) return [] + if (this._isEmptySelect(select as Record)) return [] return this.getObjects({ className, @@ -1216,7 +1302,7 @@ export class DatabaseController { }, })) as unknown as OutputType - if (select && Object.keys(select).length === 0) return null as any + if (this._isEmptySelect(select as Record)) return null as any return result } diff --git a/packages/wabe/src/database/index.test.ts b/packages/wabe/src/database/index.test.ts index 1dd7baab..9c072b77 100644 --- a/packages/wabe/src/database/index.test.ts +++ b/packages/wabe/src/database/index.test.ts @@ -408,6 +408,185 @@ describe('Database', () => { expect(res[0].userTest).toEqual([{ name: 'test', id: expect.any(String) }]) }) + it('should filter relation with have through databaseController where', async () => { + const matchingUser = await wabe.controllers.database.createObject({ + className: 'User', + context, + data: { name: 'match-user' }, + select: { id: true }, + }) + const otherUser = await wabe.controllers.database.createObject({ + className: 'User', + context, + data: { name: 'other-user' }, + select: { id: true }, + }) + + await wabe.controllers.database.createObjects({ + // @ts-expect-error + className: 'Test2', + context, + data: [ + // @ts-expect-error + { name: 'match-parent', userTest: [matchingUser?.id] }, + // @ts-expect-error + { name: 'other-parent', userTest: [otherUser?.id] }, + ], + select: { id: true }, + }) + + const filtered = await wabe.controllers.database.getObjects({ + // @ts-expect-error + className: 'Test2', + context, + // @ts-expect-error relation where type is not fully expressed in DevWabeTypes + where: { userTest: { have: { name: { equalTo: 'match-user' } } } }, + // @ts-expect-error Test2 fields are runtime-defined in setup schema + select: { id: true, name: true }, + }) + + expect(filtered.length).toBe(1) + expect((filtered[0] as any)?.name).toBe('match-parent') + }) + + it('should filter relation with isEmpty true and false through databaseController where', async () => { + const linkedUser = await wabe.controllers.database.createObject({ + className: 'User', + context, + data: { name: 'linked-user' }, + select: { id: true }, + }) + + await wabe.controllers.database.createObjects({ + // @ts-expect-error + className: 'Test2', + context, + data: [ + // @ts-expect-error + { name: 'empty-parent' }, + // @ts-expect-error + { name: 'non-empty-parent', userTest: [linkedUser?.id] }, + ], + select: { id: true }, + }) + + const emptyRelation = await wabe.controllers.database.getObjects({ + // @ts-expect-error + className: 'Test2', + context, + // @ts-expect-error relation where type is not fully expressed in DevWabeTypes + where: { userTest: { isEmpty: true } }, + // @ts-expect-error Test2 fields are runtime-defined in setup schema + select: { name: true }, + }) + + const nonEmptyRelation = await wabe.controllers.database.getObjects({ + // @ts-expect-error + className: 'Test2', + context, + // @ts-expect-error relation where type is not fully expressed in DevWabeTypes + where: { userTest: { isEmpty: false } }, + // @ts-expect-error Test2 fields are runtime-defined in setup schema + select: { name: true }, + }) + + expect(emptyRelation.length).toBe(1) + expect((emptyRelation[0] as any)?.name).toBe('empty-parent') + expect(nonEmptyRelation.length).toBe(1) + expect((nonEmptyRelation[0] as any)?.name).toBe('non-empty-parent') + }) + + it('should support relation where with nested AND/OR composition', async () => { + const targetUser = await wabe.controllers.database.createObject({ + className: 'User', + context, + data: { name: 'target-user' }, + select: { id: true }, + }) + const nonTargetUser = await wabe.controllers.database.createObject({ + className: 'User', + context, + data: { name: 'non-target-user' }, + select: { id: true }, + }) + + await wabe.controllers.database.createObjects({ + // @ts-expect-error + className: 'Test2', + context, + data: [ + // @ts-expect-error + { name: 'candidate', userTest: [targetUser?.id] }, + // @ts-expect-error + { name: 'candidate', userTest: [nonTargetUser?.id] }, + // @ts-expect-error + { name: 'outside-or' }, + ], + select: { id: true }, + }) + + const filtered = await wabe.controllers.database.getObjects({ + // @ts-expect-error + className: 'Test2', + context, + where: { + AND: [ + { + // @ts-expect-error Test2 fields are runtime-defined in setup schema + OR: [{ name: { equalTo: 'candidate' } }, { name: { equalTo: 'other' } }], + }, + { + // @ts-expect-error relation where type is not fully expressed in DevWabeTypes + userTest: { have: { name: { equalTo: 'target-user' } } }, + }, + ], + }, + // @ts-expect-error Test2 fields are runtime-defined in setup schema + select: { id: true, name: true }, + }) + + expect(filtered.length).toBe(1) + expect((filtered[0] as any)?.name).toBe('candidate') + }) + + it('should support count with relation have where filter', async () => { + const includedUser = await wabe.controllers.database.createObject({ + className: 'User', + context, + data: { name: 'included-user' }, + select: { id: true }, + }) + const excludedUser = await wabe.controllers.database.createObject({ + className: 'User', + context, + data: { name: 'excluded-user' }, + select: { id: true }, + }) + + await wabe.controllers.database.createObjects({ + // @ts-expect-error + className: 'Test2', + context, + data: [ + // @ts-expect-error + { name: 'included-parent', userTest: [includedUser?.id] }, + // @ts-expect-error + { name: 'excluded-parent', userTest: [excludedUser?.id] }, + ], + select: { id: true }, + }) + + const totalCount = await wabe.controllers.database.count({ + // @ts-expect-error + className: 'Test2', + context, + // @ts-expect-error relation where type is not fully expressed in DevWabeTypes + where: { userTest: { have: { name: { equalTo: 'included-user' } } } }, + }) + + expect(totalCount).toBe(1) + }) + it("should return null on a pointer if the pointer doesn't exist", async () => { await getGraphqlClient(wabe.config.port).request(graphql.signUpWith, { input: {