feat: soft delete (#6576)

Implement soft delete on standards and custom objects.
This is a temporary solution, when we drop `pg_graphql` we should rely
on the `softDelete` functions of TypeORM.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Jérémy M
2024-08-16 21:20:02 +02:00
committed by GitHub
parent 20d84755bb
commit db54469c8a
118 changed files with 1675 additions and 492 deletions

View File

@ -13,7 +13,7 @@ import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useDeleteOneRecordProps = {
type useDeleteManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
@ -25,7 +25,7 @@ type DeleteManyRecordsOptions = {
export const useDeleteManyRecords = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
}: useDeleteManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =

View File

@ -0,0 +1,41 @@
import gql from 'graphql-tag';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useDestroyManyRecordsMutation = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
if (isUndefinedOrNull(objectMetadataItem)) {
return { destroyManyRecordsMutation: EMPTY_MUTATION };
}
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const destroyManyRecordsMutation = gql`
mutation DestroyMany${capitalizedObjectName}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput!) {
${mutationResponseField}(filter: $filter) {
id
}
}
`;
return {
destroyManyRecordsMutation,
};
};

View File

@ -0,0 +1,115 @@
import { useApolloClient } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useDestroyManyRecordsMutation } from '@/object-record/hooks/useDestroyManyRecordMutation';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useDestroyManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
type DestroyManyRecordsOptions = {
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
export const useDestroyManyRecords = ({
objectNameSingular,
}: useDestroyManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular,
});
const { destroyManyRecordsMutation } = useDestroyManyRecordsMutation({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const destroyManyRecords = async (
idsToDestroy: string[],
options?: DestroyManyRecordsOptions,
) => {
const numberOfBatches = Math.ceil(idsToDestroy.length / mutationPageSize);
const destroyedRecords = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchIds = idsToDestroy.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
const destroyedRecordsResponse = await apolloClient.mutate({
mutation: destroyManyRecordsMutation,
variables: {
filter: { id: { in: batchIds } },
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: batchIds.map((idToDestroy) => ({
__typename: capitalize(objectNameSingular),
id: idToDestroy,
})),
},
update: options?.skipOptimisticEffect
? undefined
: (cache, { data }) => {
const records = data?.[mutationResponseField];
if (!records?.length) return;
const cachedRecords = records
.map((record) => getRecordFromCache(record.id, cache))
.filter(isDefined);
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToDelete: cachedRecords,
objectMetadataItems,
});
},
});
const destroyedRecordsForThisBatch =
destroyedRecordsResponse.data?.[mutationResponseField] ?? [];
destroyedRecords.push(...destroyedRecordsForThisBatch);
if (isDefined(options?.delayInMsBetweenRequests)) {
await sleep(options.delayInMsBetweenRequests);
}
}
return destroyedRecords;
};
return { destroyManyRecords };
};

View File

@ -17,11 +17,13 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
recordGqlFields,
onCompleted,
skip,
withSoftDeleted = false,
}: ObjectMetadataItemIdentifier & {
objectRecordId: string | undefined;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
onCompleted?: (data: T) => void;
skip?: boolean;
withSoftDeleted?: boolean;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -33,6 +35,7 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
const { findOneRecordQuery } = useFindOneRecordQuery({
objectNameSingular,
recordGqlFields: computedRecordGqlFields,
withSoftDeleted,
});
const { data, loading, error } = useQuery<{

View File

@ -10,9 +10,11 @@ import { capitalize } from '~/utils/string/capitalize';
export const useFindOneRecordQuery = ({
objectNameSingular,
recordGqlFields,
withSoftDeleted = false,
}: {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
withSoftDeleted?: boolean;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -25,6 +27,16 @@ export const useFindOneRecordQuery = ({
objectMetadataItem.nameSingular,
)}($objectRecordId: ID!) {
${objectMetadataItem.nameSingular}(filter: {
${
withSoftDeleted
? `
or: [
{ deletedAt: { is: NULL } },
{ deletedAt: { is: NOT_NULL } }
],
`
: ''
}
id: {
eq: $objectRecordId
}

View File

@ -0,0 +1,94 @@
import { useApolloClient } from '@apollo/client';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useRestoreManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
type RestoreManyRecordsOptions = {
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
export const useRestoreManyRecords = ({
objectNameSingular,
}: useRestoreManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { restoreManyRecordsMutation } = useRestoreManyRecordsMutation({
objectNameSingular,
});
const mutationResponseField = getRestoreManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const restoreManyRecords = async (
idsToRestore: string[],
options?: RestoreManyRecordsOptions,
) => {
const numberOfBatches = Math.ceil(idsToRestore.length / mutationPageSize);
const restoredRecords = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchIds = idsToRestore.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
// TODO: fix optimistic effect
const findOneQueryName = `FindOne${capitalize(objectNameSingular)}`;
const findManyQueryName = `FindMany${capitalize(objectMetadataItem.namePlural)}`;
const restoredRecordsResponse = await apolloClient.mutate({
mutation: restoreManyRecordsMutation,
refetchQueries: [findOneQueryName, findManyQueryName],
variables: {
filter: { id: { in: batchIds } },
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: batchIds.map((idToRestore) => ({
__typename: capitalize(objectNameSingular),
id: idToRestore,
deletedAt: null,
})),
},
});
const restoredRecordsForThisBatch =
restoredRecordsResponse.data?.[mutationResponseField] ?? [];
restoredRecords.push(...restoredRecordsForThisBatch);
if (isDefined(options?.delayInMsBetweenRequests)) {
await sleep(options.delayInMsBetweenRequests);
}
}
return restoredRecords;
};
return { restoreManyRecords };
};

View File

@ -0,0 +1,41 @@
import gql from 'graphql-tag';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useRestoreManyRecordsMutation = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
if (isUndefinedOrNull(objectMetadataItem)) {
return { restoreManyRecordsMutation: EMPTY_MUTATION };
}
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
const mutationResponseField = getRestoreManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const restoreManyRecordsMutation = gql`
mutation RestoreMany${capitalizedObjectName}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput!) {
${mutationResponseField}(filter: $filter) {
id
}
}
`;
return {
restoreManyRecordsMutation,
};
};