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)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23832?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
+3
@@ -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,
|
||||
});
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+3
@@ -27,6 +27,7 @@ type ProcessGroupByConnectionWithRecordsArgs = {
|
||||
| Array<Record<string, boolean | Record<string, string>>>
|
||||
| Record<string, boolean | Record<string, string>>;
|
||||
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(
|
||||
|
||||
+4
@@ -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 (
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
});
|
||||
|
||||
+3
@@ -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<number | undefined>(
|
||||
@@ -160,6 +162,7 @@ export const triggerUpdateRecordOptimisticEffect = ({
|
||||
triggerUpdateGroupByQueriesOptimisticEffect({
|
||||
cache,
|
||||
objectMetadataItem,
|
||||
objectMetadataItems,
|
||||
operation: 'update',
|
||||
records: [updatedRecord],
|
||||
shouldMatchRootQueryFilter: true,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+219
-4
@@ -11,10 +11,11 @@ const companiesMock = mockedCompanyRecords.map((record) =>
|
||||
getRecordFromRecordNode<Company>({ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+92
@@ -94,14 +94,83 @@ const isNotFilter = (
|
||||
filter: RecordGqlOperationFilter,
|
||||
): filter is NotObjectRecordFilter => 'not' in filter && !!filter.not;
|
||||
|
||||
const UUID_FILTER_OPERATOR_KEYS = new Set<string>([
|
||||
'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<FieldMetadataItem, 'relation'>;
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user