a0281f635b
Fixes #23305. Removing a junction relation soft-deletes the intermediate junction record. Re-adding the same relation created a brand new record, which the composite unique index on the junction object (e.g. `personId` + `companyId`) rejected with "This record already exists". `useUpdateJunctionRelationFromCell` now creates the junction record through `useCreateManyRecords` with `upsert: true`. The server matches on the unique index with `withDeleted()` and clears `deletedAt` on the matched row instead of inserting. ## This revives, it does not create Worth being explicit, because it is a deliberate trade and not obvious from the diff. When a soft-deleted row exists for the pair, the user gets that row back. Same id, same `createdAt`, same `createdBy`, and anything attached to it (notes, files, and any fields the app added to the junction object). Verified on a dev instance: after re-adding a link through the picker, the row still reported a creation date from days earlier. That is fine for a pure link. It is a lie for a junction that carries data, for instance a `PersonCompanyRelationship` with a role and dates. The alternative designs are hard-deleting on detach (needs `canDestroyObjectRecords` on the junction object, which most roles do not grant) and partial unique indexes (needs `indexWhereClause` exposed to app-declared indexes, which the SDK does not support today). Both are larger changes. This one unblocks affected apps without requiring anything from them. Note that upsert conflict detection ignores `indexWhereClause`, so shipping partial indexes later would not change this behaviour on its own. ## Why the id handling changed The hook no longer sends a client-generated id. Under upsert the server decides which row you get, so the id in the input would be discarded. The optimistic store entry still uses a local id so the chip appears immediately, then adopts the persisted id once the mutation resolves. Without that, a revive left an id in the store that exists nowhere on the server, and the next removal failed with "This record does not exist or has been deleted". Supersedes #23365, which took the same approach but added `$upsert` to the shared `createOne` mutation. That capability already exists per call on `createMany`, and widening the shared document broke the `useCreateOneRecordMutation` and `useCreateOneRecord` tests. ## Testing Verified end to end on a dev instance with a junction object carrying a non-partial composite unique index, since the dev seed does not create one and the bug cannot reproduce without it: - before: re-adding after a removal fails with "This record already exists" - after: the original row is restored, `deletedAt` cleared, one row throughout, and removing again in the same session works, with no GraphQL errors Added an integration test for the path this depends on: a soft-deleted record matched on its unique fields alone, with no id in the input. The existing coverage only exercised upsert by explicit id. ## Known gaps, deliberately not addressed here **Toggling a link off while its creation is still in flight.** The store id is provisional until the mutation returns, so a removal issued inside that window deletes an id the server never received. Reproduced by stalling the create and clicking remove during it: `CombinedGraphQLErrors: Record not found`, and the link stays active despite the user removing it. The window is one mutation round trip. Left for a follow-up; the likely fix is a per-record operation queue so adds and removes on a field run in click order. **Junction objects with no unique index.** Remove then re-add still creates a duplicate row there, on this branch as on main, because conflict detection is driven by unique indexes. --------- Co-authored-by: Shinu Cherian <129690295+Shinu-Cherian@users.noreply.github.com>
422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
import gql from 'graphql-tag';
|
|
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
|
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
|
|
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
|
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
|
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
|
|
import { FieldMetadataType } from 'twenty-shared/types';
|
|
|
|
const createRecordsQuery = gql`
|
|
mutation CreateRecords(
|
|
$data: [TestRecordObjectCreateInput!]!
|
|
$upsert: Boolean
|
|
) {
|
|
createTestRecordObjects(data: $data, upsert: $upsert) {
|
|
id
|
|
firstUniqueTestField
|
|
secondUniqueTestField
|
|
name
|
|
deletedAt
|
|
}
|
|
}
|
|
`;
|
|
|
|
const deleteRecordsQuery = gql`
|
|
mutation DeleteRecords($filter: TestRecordObjectFilterInput!) {
|
|
deleteTestRecordObjects(filter: $filter) {
|
|
id
|
|
firstUniqueTestField
|
|
secondUniqueTestField
|
|
name
|
|
deletedAt
|
|
}
|
|
}
|
|
`;
|
|
|
|
const createRecordsWithPositionQuery = gql`
|
|
mutation CreateRecordsWithPosition(
|
|
$data: [TestRecordObjectCreateInput!]!
|
|
$upsert: Boolean
|
|
) {
|
|
createTestRecordObjects(data: $data, upsert: $upsert) {
|
|
id
|
|
firstUniqueTestField
|
|
name
|
|
position
|
|
}
|
|
}
|
|
`;
|
|
|
|
describe('upsert (createMany with upsert:true)', () => {
|
|
let createdObjectMetadataId = '';
|
|
|
|
beforeEach(async () => {
|
|
const {
|
|
data: {
|
|
createOneObject: { id: objectMetadataId },
|
|
},
|
|
} = await createOneObjectMetadata({
|
|
input: {
|
|
nameSingular: 'testRecordObject',
|
|
namePlural: 'testRecordObjects',
|
|
labelSingular: 'Test Record Object',
|
|
labelPlural: 'Test Record Objects',
|
|
icon: 'IconTestRecord',
|
|
},
|
|
});
|
|
|
|
createdObjectMetadataId = objectMetadataId;
|
|
|
|
await createOneFieldMetadata({
|
|
input: {
|
|
name: 'firstUniqueTestField',
|
|
label: 'First Unique Test Field',
|
|
type: FieldMetadataType.TEXT,
|
|
objectMetadataId: createdObjectMetadataId,
|
|
isUnique: true,
|
|
},
|
|
gqlFields: `
|
|
id
|
|
name
|
|
label
|
|
type
|
|
isUnique
|
|
`,
|
|
});
|
|
|
|
await createOneFieldMetadata({
|
|
input: {
|
|
name: 'secondUniqueTestField',
|
|
label: 'Second Unique Test Field',
|
|
type: FieldMetadataType.TEXT,
|
|
objectMetadataId: createdObjectMetadataId,
|
|
isUnique: true,
|
|
},
|
|
gqlFields: `
|
|
id
|
|
name
|
|
label
|
|
type
|
|
isUnique
|
|
`,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await updateOneObjectMetadata({
|
|
expectToFail: false,
|
|
input: {
|
|
idToUpdate: createdObjectMetadataId,
|
|
updatePayload: {
|
|
isActive: false,
|
|
},
|
|
},
|
|
});
|
|
await deleteOneObjectMetadata({
|
|
input: { idToDelete: createdObjectMetadataId },
|
|
});
|
|
});
|
|
|
|
it('should update many records', async () => {
|
|
// Create 2 records
|
|
await makeGraphqlAPIRequest({
|
|
query: createRecordsQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'firstUniqueTestField1',
|
|
secondUniqueTestField: 'secondUniqueTestField1',
|
|
name: 'record1',
|
|
},
|
|
{
|
|
firstUniqueTestField: 'firstUniqueTestField2',
|
|
secondUniqueTestField: 'secondUniqueTestField2',
|
|
name: 'record2',
|
|
},
|
|
],
|
|
upsert: false,
|
|
},
|
|
});
|
|
|
|
// Update 2 records using upsert
|
|
const updatedRecordsResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'firstUniqueTestField1',
|
|
name: 'updatedRecord1',
|
|
},
|
|
{
|
|
firstUniqueTestField: 'firstUniqueTestField2',
|
|
name: 'updatedRecord2',
|
|
},
|
|
],
|
|
upsert: true,
|
|
},
|
|
});
|
|
|
|
const updatedRecords =
|
|
updatedRecordsResponse.body.data.createTestRecordObjects;
|
|
|
|
expect(updatedRecords).toHaveLength(2);
|
|
|
|
const record1 = updatedRecords.find(
|
|
(record: any) => record.firstUniqueTestField === 'firstUniqueTestField1',
|
|
);
|
|
const record2 = updatedRecords.find(
|
|
(record: any) => record.firstUniqueTestField === 'firstUniqueTestField2',
|
|
);
|
|
|
|
expect(record1).toEqual({
|
|
id: expect.any(String),
|
|
firstUniqueTestField: 'firstUniqueTestField1',
|
|
secondUniqueTestField: 'secondUniqueTestField1',
|
|
name: 'updatedRecord1',
|
|
deletedAt: null,
|
|
});
|
|
|
|
expect(record2).toEqual({
|
|
id: expect.any(String),
|
|
firstUniqueTestField: 'firstUniqueTestField2',
|
|
secondUniqueTestField: 'secondUniqueTestField2',
|
|
name: 'updatedRecord2',
|
|
deletedAt: null,
|
|
});
|
|
});
|
|
|
|
it('should throw an error when multiple records with the same unique field values are found', async () => {
|
|
await makeGraphqlAPIRequest({
|
|
query: createRecordsQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'firstUniqueTestField1',
|
|
secondUniqueTestField: 'secondUniqueTestField1',
|
|
name: 'record1',
|
|
},
|
|
{
|
|
firstUniqueTestField: 'firstUniqueTestField2',
|
|
secondUniqueTestField: 'secondUniqueTestField2',
|
|
name: 'record2',
|
|
},
|
|
],
|
|
upsert: false,
|
|
},
|
|
});
|
|
|
|
const upsertResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'firstUniqueTestField1',
|
|
secondUniqueTestField: 'secondUniqueTestField2',
|
|
name: 'conflictingRecord',
|
|
},
|
|
],
|
|
upsert: true,
|
|
},
|
|
});
|
|
|
|
expect(upsertResponse.body.errors).toBeDefined();
|
|
expect(upsertResponse.body.errors[0].message).toContain(
|
|
'Multiple records found with the same unique field values',
|
|
);
|
|
expect(upsertResponse.body.errors[0].extensions.code).toBe(
|
|
'BAD_USER_INPUT',
|
|
);
|
|
});
|
|
|
|
it('should restore a soft-deleted record matched on its unique fields only', async () => {
|
|
const createResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'softDeletedByUniqueFields',
|
|
secondUniqueTestField: 'softDeletedByUniqueFieldsSecond',
|
|
name: 'originalRecord',
|
|
},
|
|
],
|
|
upsert: false,
|
|
},
|
|
});
|
|
|
|
const createdRecord = createResponse.body.data.createTestRecordObjects[0];
|
|
|
|
await makeGraphqlAPIRequest({
|
|
query: deleteRecordsQuery,
|
|
variables: {
|
|
filter: { id: { eq: createdRecord.id } },
|
|
},
|
|
});
|
|
|
|
const upsertResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'softDeletedByUniqueFields',
|
|
secondUniqueTestField: 'softDeletedByUniqueFieldsSecond',
|
|
},
|
|
],
|
|
upsert: true,
|
|
},
|
|
});
|
|
|
|
const upsertedRecord = upsertResponse.body.data.createTestRecordObjects[0];
|
|
|
|
expect(upsertedRecord.id).toEqual(createdRecord.id);
|
|
expect(upsertedRecord.deletedAt).toBeNull();
|
|
expect(upsertedRecord.name).toEqual('originalRecord');
|
|
});
|
|
|
|
it('should update and restore updated soft-deleted record', async () => {
|
|
const createResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'softDeletedRecord',
|
|
secondUniqueTestField: 'softDeletedSecondField',
|
|
name: 'originalRecord',
|
|
},
|
|
],
|
|
upsert: false,
|
|
},
|
|
});
|
|
|
|
const createdRecord = createResponse.body.data.createTestRecordObjects[0];
|
|
|
|
const deleteResponse = await makeGraphqlAPIRequest({
|
|
query: deleteRecordsQuery,
|
|
variables: {
|
|
filter: { id: { eq: createdRecord.id } },
|
|
},
|
|
});
|
|
|
|
const updateResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsQuery,
|
|
variables: {
|
|
data: [{ id: createdRecord.id, name: 'updatedRecord' }],
|
|
upsert: true,
|
|
},
|
|
});
|
|
|
|
expect(
|
|
deleteResponse.body.data.deleteTestRecordObjects[0].deletedAt,
|
|
).toEqual(expect.any(String));
|
|
expect(
|
|
updateResponse.body.data.createTestRecordObjects[0].deletedAt,
|
|
).toBeNull();
|
|
expect(updateResponse.body.data.createTestRecordObjects[0].id).toEqual(
|
|
createdRecord.id,
|
|
);
|
|
});
|
|
|
|
it('should not change the position of an existing record when upserting without a position', async () => {
|
|
const createResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsWithPositionQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'positionTestField',
|
|
secondUniqueTestField: 'positionTestSecondField',
|
|
name: 'originalRecord',
|
|
},
|
|
],
|
|
upsert: false,
|
|
},
|
|
});
|
|
|
|
const createdRecord = createResponse.body.data.createTestRecordObjects[0];
|
|
const initialPosition = createdRecord.position;
|
|
|
|
expect(typeof initialPosition).toBe('number');
|
|
|
|
const upsertResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsWithPositionQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'positionTestField',
|
|
name: 'updatedRecord',
|
|
},
|
|
],
|
|
upsert: true,
|
|
},
|
|
});
|
|
|
|
const upsertedRecord = upsertResponse.body.data.createTestRecordObjects[0];
|
|
|
|
expect(upsertedRecord.id).toEqual(createdRecord.id);
|
|
expect(upsertedRecord.name).toEqual('updatedRecord');
|
|
expect(upsertedRecord.position).toEqual(initialPosition);
|
|
});
|
|
|
|
it('should auto-assign a position when an upsert creates a new record', async () => {
|
|
const upsertResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsWithPositionQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'insertedViaUpsertField',
|
|
secondUniqueTestField: 'insertedViaUpsertSecondField',
|
|
name: 'insertedRecord',
|
|
},
|
|
],
|
|
upsert: true,
|
|
},
|
|
});
|
|
|
|
const insertedRecord = upsertResponse.body.data.createTestRecordObjects[0];
|
|
|
|
expect(insertedRecord.id).toEqual(expect.any(String));
|
|
expect(typeof insertedRecord.position).toBe('number');
|
|
});
|
|
|
|
it('should update the position of an existing record when upserting with a position', async () => {
|
|
const createResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsWithPositionQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'updatePositionTestField',
|
|
secondUniqueTestField: 'updatePositionTestSecondField',
|
|
name: 'originalRecord',
|
|
position: 1,
|
|
},
|
|
],
|
|
upsert: false,
|
|
},
|
|
});
|
|
|
|
const createdRecord = createResponse.body.data.createTestRecordObjects[0];
|
|
|
|
expect(createdRecord.position).toEqual(1);
|
|
|
|
const newPosition = 5;
|
|
|
|
const upsertResponse = await makeGraphqlAPIRequest({
|
|
query: createRecordsWithPositionQuery,
|
|
variables: {
|
|
data: [
|
|
{
|
|
firstUniqueTestField: 'updatePositionTestField',
|
|
name: 'updatedRecord',
|
|
position: newPosition,
|
|
},
|
|
],
|
|
upsert: true,
|
|
},
|
|
});
|
|
|
|
const upsertedRecord = upsertResponse.body.data.createTestRecordObjects[0];
|
|
|
|
expect(upsertedRecord.id).toEqual(createdRecord.id);
|
|
expect(upsertedRecord.name).toEqual('updatedRecord');
|
|
expect(upsertedRecord.position).toEqual(newPosition);
|
|
});
|
|
});
|