fix: match relation field filters in optimistic & RLS record matchers (#21301)
Closes #21345. ## What It should be caused by the GraphQL optimistic query. `isRecordMatchingFilter` (front, Apollo optimistic cache) and `isRecordMatchingRLSRowLevelPermissionPredicate` (server, RLS) now handle a view filter that targets a **relation field object** (e.g. an "is (not) empty" filter on a relation) by matching against the related record id, instead of throwing. <img width="3436" height="2250" alt="CleanShot 2026-06-08 at 06 44 01@2x" src="https://github.com/user-attachments/assets/1dccbd1e-133c-4f4a-a0a9-7ccd02a9a0ae" /> <img width="1496" height="380" alt="CleanShot 2026-06-08 at 06 45 34@2x" src="https://github.com/user-attachments/assets/e5c2071e-69df-4d99-bb7d-66d503a175b6" /> ## Why Both matchers only implemented the relation **join column** branch (`fooId`) and threw `Not implemented yet, use UUID filter instead on the corresponding "fooId" field` for the relation field itself (`foo`). In practice the UI still stores relation filters keyed on the relation object, so any view with such a filter made every create/update/delete on that object throw: the optimistic effect re-evaluates all active view filters against the changed record and hits the unimplemented branch. Repro: add a self-relation field on People (e.g. "Referred By"), put it in a view filter as "is not empty", then edit any Person. The optimistic update throws. ## Behaviour change | Scenario | Before | After | |---|---|---| | View filter on relation object (`referredBy is not empty`), then edit a record | Throws `Not implemented yet...` | Record matched by related id; update succeeds | | Filter on relation join column (`referredById`) | Worked | Unchanged | ## Test plan ```bash cd packages/twenty-front && npx jest isRecordMatchingFilter cd packages/twenty-server && npx jest is-record-matching-rls-row-level-permission-predicate ``` - [x] Front: relation `is empty` / `is not empty` / `in` match by related id; join-column path still passes (20/20) - [x] Server: relation `is empty` / `is not empty` match by related id (9/9) - [x] `lint:diff-with-main` clean on both packages Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+92
@@ -535,4 +535,96 @@ describe('isRecordMatchingFilter', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relation Filters', () => {
|
||||
const accountOwnerId = '20202020-0687-4c41-b707-ed1bfca972a7';
|
||||
|
||||
const companyWithAccountOwner = {
|
||||
...companiesMock[0],
|
||||
accountOwner: { id: accountOwnerId },
|
||||
accountOwnerId,
|
||||
};
|
||||
|
||||
const companyWithoutAccountOwner = {
|
||||
...companyWithAccountOwner,
|
||||
accountOwner: null,
|
||||
accountOwnerId: null,
|
||||
};
|
||||
|
||||
it('matches "is not empty" on a relation field by its related record id', () => {
|
||||
const filter: RecordGqlOperationFilter = {
|
||||
accountOwner: { is: 'NOT_NULL' },
|
||||
};
|
||||
|
||||
expect(
|
||||
isRecordMatchingFilter({
|
||||
record: companyWithAccountOwner,
|
||||
filter,
|
||||
objectMetadataItem: companyMockObjectMetadataItem,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRecordMatchingFilter({
|
||||
record: companyWithoutAccountOwner,
|
||||
filter,
|
||||
objectMetadataItem: companyMockObjectMetadataItem,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('matches "is empty" on a relation field by its related record id', () => {
|
||||
const filter: RecordGqlOperationFilter = {
|
||||
accountOwner: { is: 'NULL' },
|
||||
};
|
||||
|
||||
expect(
|
||||
isRecordMatchingFilter({
|
||||
record: companyWithoutAccountOwner,
|
||||
filter,
|
||||
objectMetadataItem: companyMockObjectMetadataItem,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRecordMatchingFilter({
|
||||
record: companyWithAccountOwner,
|
||||
filter,
|
||||
objectMetadataItem: companyMockObjectMetadataItem,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('matches an "in" filter on a relation field by its related record id', () => {
|
||||
expect(
|
||||
isRecordMatchingFilter({
|
||||
record: companyWithAccountOwner,
|
||||
filter: { accountOwner: { in: [accountOwnerId] } },
|
||||
objectMetadataItem: companyMockObjectMetadataItem,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRecordMatchingFilter({
|
||||
record: companyWithAccountOwner,
|
||||
filter: { accountOwner: { in: ['unknown-id'] } },
|
||||
objectMetadataItem: companyMockObjectMetadataItem,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('still matches the relation join column field', () => {
|
||||
const filter: RecordGqlOperationFilter = {
|
||||
accountOwnerId: { is: 'NOT_NULL' },
|
||||
};
|
||||
|
||||
expect(
|
||||
isRecordMatchingFilter({
|
||||
record: companyWithAccountOwner,
|
||||
filter,
|
||||
objectMetadataItem: companyMockObjectMetadataItem,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-3
@@ -434,9 +434,10 @@ export const isRecordMatchingFilter = ({
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Not implemented yet, use UUID filter instead on the corresponding "${filterKey}Id" field`,
|
||||
);
|
||||
return isMatchingUUIDFilter({
|
||||
uuidFilter: filterValue as UUIDFilter,
|
||||
value: record[filterKey]?.id ?? null,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.TS_VECTOR: {
|
||||
return isMatchingTSVectorFilter({
|
||||
|
||||
+40
@@ -286,4 +286,44 @@ describe('isRecordMatchingRLSRowLevelPermissionPredicate', () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('matches "is not empty" on a relation field by its related record id', () => {
|
||||
expect(
|
||||
isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: { ...baseRecord, company: { id: 'company-1' } } as ObjectRecord,
|
||||
filter: { company: { is: 'NOT_NULL' } },
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: { ...baseRecord, company: null } as ObjectRecord,
|
||||
filter: { company: { is: 'NOT_NULL' } },
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('matches "is empty" on a relation field by its related record id', () => {
|
||||
expect(
|
||||
isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: { ...baseRecord, company: null } as ObjectRecord,
|
||||
filter: { company: { is: 'NULL' } },
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRecordMatchingRLSRowLevelPermissionPredicate({
|
||||
record: { ...baseRecord, company: { id: 'company-1' } } as ObjectRecord,
|
||||
filter: { company: { is: 'NULL' } },
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-3
@@ -426,9 +426,10 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Not implemented yet, use UUID filter instead on the corresponding "${filterKey}Id" field`,
|
||||
);
|
||||
return isMatchingUUIDFilter({
|
||||
uuidFilter: filterValue as UUIDFilter,
|
||||
value: recordFieldValue?.id ?? null,
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.TS_VECTOR: {
|
||||
return isMatchingTSVectorFilter({
|
||||
|
||||
Reference in New Issue
Block a user