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:
Joseph Chiang
2026-06-11 15:29:10 +10:00
committed by GitHub
parent 61b76b681e
commit 941c9e7586
4 changed files with 140 additions and 6 deletions
@@ -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);
});
});
});
@@ -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({
@@ -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);
});
});
@@ -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({