From c3d0f021b74aec8786e558ae2b5c5f487bb3207a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 5 Aug 2026 21:10:49 +0200 Subject: [PATCH] Fix client-side record matching for nested relation filters (#23832) ## Problem `isRecordMatchingFilter` assumes every filter keyed by a relation field name is a flat UUID filter on the related record id. Filters that traverse a relation, like ``` { pointOfContact: { companyId: { in: [companyId] } } } ``` (produced by view filters carrying `relationTargetFieldMetadataId`, such as the seeded filter of a nested relation Field widget in #23815), make it throw `Unexpected value for UUID filter`. The throw happens inside the create and update optimistic effects, so creating a record from a view seeded with such a filter aborts before anything is written. ## Fix When the value under a relation field name holds related record field names (or `and`/`or`/`not` composites) instead of UUID operators, recurse into the related record with the relation target's object metadata. A related record missing from the payload, or a list relation, conservatively does not match. Flat UUID filters on the relation name, join column filters and morph relations keep their existing behavior. `isRecordMatchingFilter` now takes `objectMetadataItems` to resolve the relation target metadata. The optimistic effect call sites already had it in scope; it is threaded through the two group-by helpers. ## Tests - New `Nested Relation Filters` cases: match, no match, related record not loaded, composite conditions, list relation. - Existing suites updated for the added parameter; record-filter and optimistic-effect suites green, typecheck green. --- _Generated by [Claude Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_ Review in cubic --- ...rocessGroupByConnectionWithRecords.test.ts | 3 + ...dateGroupByQueriesOptimisticEffect.test.ts | 4 + .../processGroupByConnectionWithRecords.ts | 3 + ...gerUpdateGroupByQueriesOptimisticEffect.ts | 4 + .../triggerCreateRecordsOptimisticEffect.ts | 2 + .../triggerDestroyRecordsOptimisticEffect.ts | 2 + .../triggerUpdateRecordOptimisticEffect.ts | 3 + ...ggerUpdateRecordOptimisticEffectByBatch.ts | 2 + .../__tests__/isRecordMatchingFilter.test.ts | 223 +++++++++++++++++- .../utils/isRecordMatchingFilter.ts | 92 ++++++++ 10 files changed, 334 insertions(+), 4 deletions(-) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/processGroupByConnectionWithRecords.test.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/processGroupByConnectionWithRecords.test.ts index 8607fc40f7..855895e2b7 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/processGroupByConnectionWithRecords.test.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/processGroupByConnectionWithRecords.test.ts @@ -55,6 +55,7 @@ describe('processGroupByConnectionWithRecords', () => { groupByDimensionValues: [], groupByConfig: undefined, objectMetadataItem: mockObjectMetadataItem, + objectMetadataItems: [mockObjectMetadataItem], readField: mockReadField, toReference: mockToReference, }); @@ -80,6 +81,7 @@ describe('processGroupByConnectionWithRecords', () => { groupByDimensionValues: [], groupByConfig: undefined, objectMetadataItem: mockObjectMetadataItem, + objectMetadataItems: [mockObjectMetadataItem], readField: mockReadField, toReference: mockToReference, }); @@ -111,6 +113,7 @@ describe('processGroupByConnectionWithRecords', () => { groupByDimensionValues: [], groupByConfig: undefined, objectMetadataItem: mockObjectMetadataItem, + objectMetadataItems: [mockObjectMetadataItem], readField: mockReadField, toReference: mockToReference, }); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/triggerUpdateGroupByQueriesOptimisticEffect.test.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/triggerUpdateGroupByQueriesOptimisticEffect.test.ts index 02b4ea7a2c..38bfca9707 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/triggerUpdateGroupByQueriesOptimisticEffect.test.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/triggerUpdateGroupByQueriesOptimisticEffect.test.ts @@ -26,6 +26,7 @@ describe('triggerUpdateGroupByQueriesOptimisticEffect', () => { triggerUpdateGroupByQueriesOptimisticEffect({ cache: mockCache, objectMetadataItem: mockObjectMetadataItem, + objectMetadataItems: [mockObjectMetadataItem], operation: 'create', records: [mockRecord], shouldMatchRootQueryFilter: false, @@ -48,6 +49,7 @@ describe('triggerUpdateGroupByQueriesOptimisticEffect', () => { triggerUpdateGroupByQueriesOptimisticEffect({ cache: mockCache, objectMetadataItem: mockObjectMetadataItem, + objectMetadataItems: [mockObjectMetadataItem], operation: 'update', records: [mockRecord], shouldMatchRootQueryFilter: false, @@ -65,6 +67,7 @@ describe('triggerUpdateGroupByQueriesOptimisticEffect', () => { triggerUpdateGroupByQueriesOptimisticEffect({ cache: mockCache, objectMetadataItem: mockObjectMetadataItem, + objectMetadataItems: [mockObjectMetadataItem], operation: 'delete', records: [mockRecord], shouldMatchRootQueryFilter: false, @@ -82,6 +85,7 @@ describe('triggerUpdateGroupByQueriesOptimisticEffect', () => { triggerUpdateGroupByQueriesOptimisticEffect({ cache: mockCache, objectMetadataItem: mockObjectMetadataItem, + objectMetadataItems: [mockObjectMetadataItem], operation: 'create', records: [], shouldMatchRootQueryFilter: false, diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/processGroupByConnectionWithRecords.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/processGroupByConnectionWithRecords.ts index 6eab0e7745..c608ee2b89 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/processGroupByConnectionWithRecords.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/processGroupByConnectionWithRecords.ts @@ -27,6 +27,7 @@ type ProcessGroupByConnectionWithRecordsArgs = { | Array>> | Record>; objectMetadataItem: EnrichedObjectMetadataItem; + objectMetadataItems: EnrichedObjectMetadataItem[]; readField: ReadFieldFunction; toReference: ToReferenceFunction; }; @@ -41,6 +42,7 @@ export const processGroupByConnectionWithRecords = ({ groupByDimensionValues, groupByConfig, objectMetadataItem, + objectMetadataItems, readField, toReference, }: ProcessGroupByConnectionWithRecordsArgs): { @@ -62,6 +64,7 @@ export const processGroupByConnectionWithRecords = ({ record, filter: queryFilter ?? {}, objectMetadataItem, + objectMetadataItems, }); const belongsToGroup = doesRecordBelongToGroup( diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect.ts index 629e443ed5..faac95b356 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect.ts @@ -17,6 +17,7 @@ import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; type TriggerUpdateGroupByQueriesOptimisticEffectArgs = { cache: ApolloCache; objectMetadataItem: EnrichedObjectMetadataItem; + objectMetadataItems: EnrichedObjectMetadataItem[]; operation: 'create' | 'update' | 'delete'; records: RecordGqlNode[]; shouldMatchRootQueryFilter?: boolean; @@ -25,6 +26,7 @@ type TriggerUpdateGroupByQueriesOptimisticEffectArgs = { export const triggerUpdateGroupByQueriesOptimisticEffect = ({ cache, objectMetadataItem, + objectMetadataItems, operation, records, shouldMatchRootQueryFilter = false, @@ -86,6 +88,7 @@ export const triggerUpdateGroupByQueriesOptimisticEffect = ({ : [], groupByConfig, objectMetadataItem, + objectMetadataItems, readField, toReference, }); @@ -122,6 +125,7 @@ export const triggerUpdateGroupByQueriesOptimisticEffect = ({ record, filter: queryFilter ?? {}, objectMetadataItem, + objectMetadataItems, }); if ( diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts index 20fd4fa995..ed5195da3a 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -135,6 +135,7 @@ export const triggerCreateRecordsOptimisticEffect = ({ record: recordToCreate, filter: rootQueryFilter, objectMetadataItem, + objectMetadataItems, }) ) { return []; @@ -200,6 +201,7 @@ export const triggerCreateRecordsOptimisticEffect = ({ triggerUpdateGroupByQueriesOptimisticEffect({ cache, objectMetadataItem, + objectMetadataItems, operation: 'create', records: recordsToCreate, shouldMatchRootQueryFilter, diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts index 8190393d5b..fa09887870 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts @@ -63,6 +63,7 @@ export const triggerDestroyRecordsOptimisticEffect = ({ record, filter: rootQueryVariables?.filter ?? {}, objectMetadataItem, + objectMetadataItems, }), ); @@ -129,6 +130,7 @@ export const triggerDestroyRecordsOptimisticEffect = ({ triggerUpdateGroupByQueriesOptimisticEffect({ cache, objectMetadataItem, + objectMetadataItems, operation: 'delete', records: recordsToDestroy, }); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index 252d90135b..ce63dde76b 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -79,12 +79,14 @@ export const triggerUpdateRecordOptimisticEffect = ({ record: updatedRecord, filter: rootQueryFilter ?? {}, objectMetadataItem, + objectMetadataItems, }); const currentRecordIndexInRootQueryEdges = isRecordMatchingFilter({ record: currentRecord, filter: rootQueryFilter ?? {}, objectMetadataItem, + objectMetadataItems, }); const totalCount = readField( @@ -160,6 +162,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ triggerUpdateGroupByQueriesOptimisticEffect({ cache, objectMetadataItem, + objectMetadataItems, operation: 'update', records: [updatedRecord], shouldMatchRootQueryFilter: true, diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts index 41b233b0cb..ce202ad795 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts @@ -84,6 +84,7 @@ export const triggerUpdateRecordOptimisticEffectByBatch = ({ record: updatedRecord, filter: rootQueryFilter ?? {}, objectMetadataItem, + objectMetadataItems, }); const updatedRecordFoundInRootQueryEdges = isDefined( @@ -145,6 +146,7 @@ export const triggerUpdateRecordOptimisticEffectByBatch = ({ triggerUpdateGroupByQueriesOptimisticEffect({ cache, objectMetadataItem, + objectMetadataItems, operation: 'update', records: updatedRecords, shouldMatchRootQueryFilter: true, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isRecordMatchingFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isRecordMatchingFilter.test.ts index 6b1eb722bc..ced20b6b58 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isRecordMatchingFilter.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isRecordMatchingFilter.test.ts @@ -11,10 +11,11 @@ const companiesMock = mockedCompanyRecords.map((record) => getRecordFromRecordNode({ recordNode: record }), ); -const companyMockObjectMetadataItem = - getTestEnrichedObjectMetadataItemsMock().find( - (item) => item.nameSingular === 'company', - )!; +const objectMetadataItems = getTestEnrichedObjectMetadataItemsMock(); + +const companyMockObjectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === 'company', +)!; describe('isRecordMatchingFilter', () => { describe('Empty Filters', () => { @@ -27,6 +28,7 @@ describe('isRecordMatchingFilter', () => { record: company, filter: emptyFilter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); }); @@ -44,6 +46,7 @@ describe('isRecordMatchingFilter', () => { record: company, filter: filterWithEmptyFields, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); }); @@ -58,6 +61,7 @@ describe('isRecordMatchingFilter', () => { record: company, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); }); @@ -72,6 +76,7 @@ describe('isRecordMatchingFilter', () => { record: company, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); }); @@ -86,6 +91,7 @@ describe('isRecordMatchingFilter', () => { record: company, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); }); @@ -110,6 +116,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -118,6 +125,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -148,6 +156,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); expect( @@ -155,6 +164,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -179,6 +189,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -187,6 +198,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -213,6 +225,7 @@ describe('isRecordMatchingFilter', () => { record: companyIdealCustomerProfileTrue, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(companyIdealCustomerProfileTrue.idealCustomerProfile); expect( @@ -220,6 +233,7 @@ describe('isRecordMatchingFilter', () => { record: companyIdealCustomerProfileFalse, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(companyIdealCustomerProfileFalse.idealCustomerProfile); }); @@ -270,6 +284,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -278,6 +293,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -313,6 +329,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); @@ -321,6 +338,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); }); @@ -365,6 +383,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -373,6 +392,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -405,6 +425,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -413,6 +434,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -443,6 +465,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -451,6 +474,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -484,6 +508,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); // Matches as Airbnb's employee count is between 10 and 100000 @@ -492,6 +517,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); // Does not match as Aircall's employee count is not within the range }); @@ -523,6 +549,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -531,6 +558,7 @@ describe('isRecordMatchingFilter', () => { record: companyMockNotInFilter, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -561,6 +589,7 @@ describe('isRecordMatchingFilter', () => { record: companyWithAccountOwner, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -569,6 +598,7 @@ describe('isRecordMatchingFilter', () => { record: companyWithoutAccountOwner, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -583,6 +613,7 @@ describe('isRecordMatchingFilter', () => { record: companyWithoutAccountOwner, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -591,6 +622,7 @@ describe('isRecordMatchingFilter', () => { record: companyWithAccountOwner, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -601,6 +633,7 @@ describe('isRecordMatchingFilter', () => { record: companyWithAccountOwner, filter: { accountOwner: { in: [accountOwnerId] } }, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); @@ -609,6 +642,7 @@ describe('isRecordMatchingFilter', () => { record: companyWithAccountOwner, filter: { accountOwner: { in: ['unknown-id'] } }, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(false); }); @@ -623,8 +657,189 @@ describe('isRecordMatchingFilter', () => { record: companyWithAccountOwner, filter, objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, }), ).toBe(true); }); }); + + describe('Nested Relation Filters', () => { + const opportunityMockObjectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === 'opportunity', + )!; + + const companyId = '20202020-171e-4bcc-9cf7-43448d6fb278'; + const personId = '20202020-2d40-4e49-8df4-9c6a049190ef'; + + const opportunityWithPointOfContact = { + id: '20202020-83f4-4c4f-95c1-b7be9f2d36d1', + pointOfContact: { id: personId, companyId }, + }; + + it('matches when the related record satisfies the nested filter', () => { + const filter = { + pointOfContact: { companyId: { in: [companyId] } }, + } as RecordGqlOperationFilter; + + expect( + isRecordMatchingFilter({ + record: opportunityWithPointOfContact, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(true); + }); + + it('does not match when the related record fails the nested filter', () => { + const filter = { + pointOfContact: { + companyId: { in: ['20202020-0000-4000-8000-000000000000'] }, + }, + } as RecordGqlOperationFilter; + + expect( + isRecordMatchingFilter({ + record: opportunityWithPointOfContact, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(false); + }); + + it('does not match when the related record is not loaded', () => { + const filter = { + pointOfContact: { companyId: { in: [companyId] } }, + } as RecordGqlOperationFilter; + + expect( + isRecordMatchingFilter({ + record: { + ...opportunityWithPointOfContact, + pointOfContact: null, + }, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(false); + + expect( + isRecordMatchingFilter({ + record: { id: opportunityWithPointOfContact.id }, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(false); + }); + + it('evaluates composite conditions against the related record', () => { + const filter = { + pointOfContact: { + and: [{ companyId: { in: [companyId] } }, { id: { eq: personId } }], + }, + } as RecordGqlOperationFilter; + + expect( + isRecordMatchingFilter({ + record: opportunityWithPointOfContact, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(true); + + const nonMatchingFilter = { + pointOfContact: { + and: [{ companyId: { in: [companyId] } }, { id: { neq: personId } }], + }, + } as RecordGqlOperationFilter; + + expect( + isRecordMatchingFilter({ + record: opportunityWithPointOfContact, + filter: nonMatchingFilter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(false); + }); + + it('keeps a record with an unloaded relation out of a negated nested filter', () => { + const filter = { + not: { pointOfContact: { companyId: { in: [companyId] } } }, + } as RecordGqlOperationFilter; + + expect( + isRecordMatchingFilter({ + record: { id: opportunityWithPointOfContact.id }, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(false); + }); + + it('evaluates a negated nested filter truthfully on a loaded relation', () => { + const filter = { + not: { pointOfContact: { companyId: { in: [companyId] } } }, + } as RecordGqlOperationFilter; + + expect( + isRecordMatchingFilter({ + record: opportunityWithPointOfContact, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(false); + + expect( + isRecordMatchingFilter({ + record: { + ...opportunityWithPointOfContact, + pointOfContact: { + id: personId, + companyId: '20202020-0000-4000-8000-000000000000', + }, + }, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(true); + + expect( + isRecordMatchingFilter({ + record: { + ...opportunityWithPointOfContact, + pointOfContact: null, + }, + filter, + objectMetadataItem: opportunityMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(true); + }); + + it('does not match a nested filter on a list of related records', () => { + const filter = { + people: { id: { eq: personId } }, + } as RecordGqlOperationFilter; + + expect( + isRecordMatchingFilter({ + record: { + ...companiesMock[0], + people: [{ id: personId }], + }, + filter, + objectMetadataItem: companyMockObjectMetadataItem, + objectMetadataItems, + }), + ).toBe(false); + }); + }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index 66d5712050..0db95a3f55 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -94,14 +94,83 @@ const isNotFilter = ( filter: RecordGqlOperationFilter, ): filter is NotObjectRecordFilter => 'not' in filter && !!filter.not; +const UUID_FILTER_OPERATOR_KEYS = new Set([ + 'eq', + 'gt', + 'gte', + 'in', + 'is', + 'lt', + 'lte', + 'neq', +]); + +// A filter on a relation field name either holds UUID operators applied to +// the related record id, or field names of the related record to match +// against the related record itself, like { person: { companyId: { in: [...] } } } +// produced by view filters traversing a relation. +const isNestedRelationFilter = ( + filterValue: unknown, +): filterValue is RecordGqlOperationFilter => + isObject(filterValue) && + Object.keys(filterValue).some((key) => !UUID_FILTER_OPERATOR_KEYS.has(key)); + +const isRecordMatchingNestedRelationFilter = ({ + relationRecord, + nestedFilter, + relationFieldMetadataItem, + objectMetadataItems, + isWithinNegatedFilter, +}: { + relationRecord: unknown; + nestedFilter: RecordGqlOperationFilter; + relationFieldMetadataItem: Pick; + objectMetadataItems: EnrichedObjectMetadataItem[]; + isWithinNegatedFilter: boolean; +}): boolean => { + // A null related record truthfully fails the nested predicate, matching + // the backend NOT EXISTS semantics. A related record that was not loaded + // leaves the outcome unknown: returning the negation parity keeps the + // record excluded whether or not a surrounding not flips the result. + if (relationRecord === null) { + return false; + } + + if (!isObject(relationRecord) || Array.isArray(relationRecord)) { + return isWithinNegatedFilter; + } + + const relationTargetObjectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === + relationFieldMetadataItem.relation?.targetObjectMetadata.id, + ); + + if (!isDefined(relationTargetObjectMetadataItem)) { + return isWithinNegatedFilter; + } + + return isRecordMatchingFilter({ + record: relationRecord, + filter: nestedFilter, + objectMetadataItem: relationTargetObjectMetadataItem, + objectMetadataItems, + isWithinNegatedFilter, + }); +}; + export const isRecordMatchingFilter = ({ record, filter, objectMetadataItem, + objectMetadataItems, + isWithinNegatedFilter = false, }: { record: any; filter: RecordGqlOperationFilter; objectMetadataItem: EnrichedObjectMetadataItem; + objectMetadataItems: EnrichedObjectMetadataItem[]; + isWithinNegatedFilter?: boolean; }): boolean => { if (Object.keys(filter).length === 0 && record.deletedAt === null) { return true; @@ -113,6 +182,8 @@ export const isRecordMatchingFilter = ({ record, filter: { [filterKey]: value }, objectMetadataItem, + objectMetadataItems, + isWithinNegatedFilter, }), ); } @@ -133,6 +204,8 @@ export const isRecordMatchingFilter = ({ record, filter: andFilter, objectMetadataItem, + objectMetadataItems, + isWithinNegatedFilter, }), ) ); @@ -149,6 +222,8 @@ export const isRecordMatchingFilter = ({ record, filter: orFilter, objectMetadataItem, + objectMetadataItems, + isWithinNegatedFilter, }), ) ); @@ -160,6 +235,8 @@ export const isRecordMatchingFilter = ({ record, filter: filterValue, objectMetadataItem, + objectMetadataItems, + isWithinNegatedFilter, }); } @@ -179,6 +256,8 @@ export const isRecordMatchingFilter = ({ record, filter: filterValue, objectMetadataItem, + objectMetadataItems, + isWithinNegatedFilter: !isWithinNegatedFilter, }) ); } @@ -434,6 +513,19 @@ export const isRecordMatchingFilter = ({ }); } + if ( + objectMetadataField.type === FieldMetadataType.RELATION && + isNestedRelationFilter(filterValue) + ) { + return isRecordMatchingNestedRelationFilter({ + relationRecord: record[filterKey], + nestedFilter: filterValue, + relationFieldMetadataItem: objectMetadataField, + objectMetadataItems, + isWithinNegatedFilter, + }); + } + return isMatchingUUIDFilter({ uuidFilter: filterValue as UUIDFilter, value: record[filterKey]?.id ?? null,