[BUG] Fix record relation optimistic mutation (#9881)

# Introduction
It seems like optimistic caching isn't working as expected for any
record relation mutation, CREATE UPDATE DELETE.
It should not have an impact on the destroy

We included a new `computeOptimisticRecordInput` that will calculate if
a relation is added or detach.

Updated the `triggerCreateRecordsOptimisticEffect` signature we should
have a look to each of its call to determine if it should be checking
cache or not

Related to #9580

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Paul Rastoin
2025-01-29 16:00:59 +01:00
committed by GitHub
parent 7291a1ddcd
commit 29745c6756
17 changed files with 502 additions and 102 deletions

View File

@ -0,0 +1,182 @@
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { InMemoryCache } from '@apollo/client';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
describe('computeOptimisticRecordFromInput', () => {
it('should generate correct optimistic record if no relation field is present', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
const result = computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
city: 'Paris',
},
cache,
});
expect(result).toEqual({
city: 'Paris',
});
});
it('should generate correct optimistic record if relation field is present but cache is empty', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
const result = computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: '123',
},
cache,
});
expect(result).toEqual({
companyId: '123',
});
});
it('should generate correct optimistic record if relation field is present and cache is not empty', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
const companyObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
);
if (!companyObjectMetadataItem) {
throw new Error('Company object metadata item not found');
}
const companyRecord = {
id: '123',
__typename: 'Company',
};
updateRecordFromCache({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: {
...companyObjectMetadataItem,
fields: companyObjectMetadataItem.fields.filter(
(field) => field.name === 'id',
),
},
cache,
record: companyRecord,
});
const result = computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: '123',
},
cache,
});
expect(result).toEqual({
companyId: '123',
company: companyRecord,
});
});
it('should generate correct optimistic record if relation field is null and cache is empty', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
const result = computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: null,
},
cache,
});
expect(result).toEqual({
companyId: null,
company: null,
});
});
it('should throw an error if recordInput contains fiels unrelated to the current objectMetadata', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
expect(() =>
computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
unknwon: 'unknown',
foo: 'foo',
bar: 'bar',
city: 'Paris',
},
cache,
}),
).toThrowErrorMatchingInlineSnapshot(
`"Should never occur, encountered unknown fields unknwon, foo, bar in objectMetadaItem person"`,
);
});
it('should throw an error if recordInput contains both the relationFieldId and relationField', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
expect(() =>
computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: '123',
company: {},
},
cache,
}),
).toThrowErrorMatchingInlineSnapshot(
`"Should never provide relation mutation through anything else than the fieldId e.g companyId"`,
);
});
});

View File

@ -0,0 +1,153 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
getRecordFromCache,
GetRecordFromCacheArgs,
} from '@/object-record/cache/utils/getRecordFromCache';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
type ComputeOptimisticCacheRecordInputArgs = {
objectMetadataItem: ObjectMetadataItem;
recordInput: Partial<ObjectRecord>;
} & Pick<GetRecordFromCacheArgs, 'cache' | 'objectMetadataItems'>;
export const computeOptimisticRecordFromInput = ({
objectMetadataItem,
recordInput,
cache,
objectMetadataItems,
}: ComputeOptimisticCacheRecordInputArgs) => {
const unknownRecordInputFields = Object.keys(recordInput).filter(
(fieldName) =>
objectMetadataItem.fields.find(({ name }) => name === fieldName) ===
undefined,
);
if (unknownRecordInputFields.length > 0) {
throw new Error(
`Should never occur, encountered unknown fields ${unknownRecordInputFields.join(', ')} in objectMetadaItem ${objectMetadataItem.nameSingular}`,
);
}
const optimisticRecord: Partial<ObjectRecord> = {};
for (const fieldMetadataItem of objectMetadataItem.fields) {
if (isFieldUuid(fieldMetadataItem)) {
const isRelationFieldId = objectMetadataItem.fields.some(
({ type, relationDefinition }) => {
if (type !== FieldMetadataType.RELATION) {
return false;
}
if (!isDefined(relationDefinition)) {
return false;
}
const sourceFieldName = relationDefinition.sourceFieldMetadata.name;
return (
getForeignKeyNameFromRelationFieldName(sourceFieldName) ===
fieldMetadataItem.name
);
},
);
if (isRelationFieldId) {
continue;
}
}
const isRelationField = isFieldRelation(fieldMetadataItem);
const recordInputFieldValue: unknown = recordInput[fieldMetadataItem.name];
if (!isRelationField) {
if (!isDefined(recordInputFieldValue)) {
continue;
}
if (!fieldMetadataItem.isNullable && recordInputFieldValue == null) {
continue;
}
optimisticRecord[fieldMetadataItem.name] = recordInputFieldValue;
continue;
}
if (
fieldMetadataItem.relationDefinition?.direction ===
RelationDefinitionType.ONE_TO_MANY
) {
continue;
}
const isManyToOneRelation =
fieldMetadataItem.relationDefinition?.direction ===
RelationDefinitionType.MANY_TO_ONE;
if (!isManyToOneRelation) {
continue;
}
if (isDefined(recordInputFieldValue)) {
throw new Error(
'Should never provide relation mutation through anything else than the fieldId e.g companyId',
);
}
const relationFieldIdName = getForeignKeyNameFromRelationFieldName(
fieldMetadataItem.name,
);
const recordInputFieldIdValue: string | null | undefined =
recordInput[relationFieldIdName];
if (isUndefined(recordInputFieldIdValue)) {
continue;
}
const relationIdFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.name === relationFieldIdName,
);
if (!isDefined(relationIdFieldMetadataItem)) {
throw new Error(
'Should never occur, encountered unknown relationId within relations definitions',
);
}
if (isNull(recordInputFieldIdValue)) {
optimisticRecord[relationFieldIdName] = null;
optimisticRecord[fieldMetadataItem.name] = null;
continue;
}
const targetNameSingular =
fieldMetadataItem.relationDefinition?.targetObjectMetadata.nameSingular;
const targetObjectMetataDataItem = objectMetadataItems.find(
({ nameSingular }) => nameSingular === targetNameSingular,
);
if (!isDefined(targetObjectMetataDataItem)) {
throw new Error(
'Should never occur, encountered invalid relation definition',
);
}
const cachedRecord = getRecordFromCache({
cache,
objectMetadataItem: targetObjectMetataDataItem,
objectMetadataItems,
recordId: recordInputFieldIdValue as string,
});
optimisticRecord[relationFieldIdName] = recordInputFieldIdValue;
if (!isDefined(cachedRecord) || Object.keys(cachedRecord).length <= 0) {
continue;
}
optimisticRecord[fieldMetadataItem.name] = cachedRecord;
}
return optimisticRecord;
};

View File

@ -0,0 +1,2 @@
export const getForeignKeyNameFromRelationFieldName = (nameSingular: string) =>
`${nameSingular}Id`;

View File

@ -1,11 +1,8 @@
import { isString } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { getUrlHostName } from '~/utils/url/getUrlHostName';
export const sanitizeRecordInput = ({
objectMetadataItem,
@ -56,15 +53,5 @@ export const sanitizeRecordInput = ({
})
.filter(isDefined),
);
if (
!(
isDefined(filteredResultRecord.domainName) &&
isString(filteredResultRecord.domainName)
)
)
return filteredResultRecord;
return {
...filteredResultRecord,
domainName: getUrlHostName(filteredResultRecord.domainName as string),
};
return filteredResultRecord;
};