feat: find duplicate objects init (#4038)

* feat: find duplicate objects backend init

* refactor: move duplicate criteria to constants

* fix: correct constant usage after type change

* feat: skip query generation in case its not necessary

* feat: filter out existing duplicate

* feat: FE queries and hooks

* feat: show duplicates on FE

* refactor: should-skip-query moved to workspace utils

* refactor: naming improvements

* refactor: current record typings/parsing improvements

* refactor: throw error if existing record not found

* fix: domain -> domainName duplicate criteria

* refactor: fieldNames -> columnNames

* docs: add explanation to duplicate criteria collection

* feat: add person linkedinLinkUrl as duplicate criteria

* feat: throw early when bot id and data are empty

* refactor: trying to improve readability of filter criteria query

* refactor: naming improvements

* refactor: remove shouldSkipQuery

* feat: resolve empty array in case of empty filter

* feat: hide whole section in case of no duplicates

* feat: FE display list the same way as relations

* test: basic unit test coverage

* Refactor Record detail section front

* Use Create as input argument of findDuplicates

* Improve coverage

* Fix

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
rostaklein 2024-02-24 19:12:21 +01:00 committed by GitHub
parent 05c206073d
commit 1b04dfe3c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 875 additions and 100 deletions

View File

@ -6,17 +6,18 @@ const globalCoverage = {
};
const modulesCoverage = {
statements: 50,
lines: 50,
functions: 45,
statements: 75,
lines: 75,
functions: 70,
include: ['src/modules/**/*'],
exclude: ['src/**/*.ts'],
};
const pagesCoverage = {
statements: 50,
lines: 50,
statements: 60,
lines: 60,
functions: 45,
exclude: ['src/generated/**/*', 'src/modules/**/*', '*.ts'],
exclude: ['src/generated/**/*', 'src/modules/**/*', 'src/**/*.ts'],
};
const storybookStoriesFolders = process.env.STORYBOOK_SCOPE;

View File

@ -16,6 +16,7 @@ import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGe
import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { useGenerateDeleteManyRecordMutation } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation';
import { useGenerateExecuteQuickActionOnOneRecordMutation } from '@/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation';
import { useGenerateFindDuplicateRecordsQuery } from '@/object-record/hooks/useGenerateFindDuplicateRecordsQuery';
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
import { useGenerateUpdateOneRecordMutation } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
@ -91,6 +92,13 @@ export const useObjectMetadataItem = (
depth,
});
const generateFindDuplicateRecordsQuery =
useGenerateFindDuplicateRecordsQuery();
const findDuplicateRecordsQuery = generateFindDuplicateRecordsQuery({
objectMetadataItem,
depth,
});
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
const findOneRecordQuery = generateFindOneRecordQuery({
objectMetadataItem,
@ -136,6 +144,7 @@ export const useObjectMetadataItem = (
getRecordFromCache,
modifyRecordFromCache,
findManyRecordsQuery,
findDuplicateRecordsQuery,
findOneRecordQuery,
createOneRecordMutation,
updateOneRecordMutation,

View File

@ -0,0 +1,79 @@
import { useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { logError } from '~/utils/logError';
import { ObjectRecordQueryResult } from '../types/ObjectRecordQueryResult';
export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
objectRecordId = '',
objectNameSingular,
onCompleted,
depth,
}: ObjectMetadataItemIdentifier & {
objectRecordId: string | undefined;
onCompleted?: (data: ObjectRecordConnection<T>) => void;
skip?: boolean;
depth?: number;
}) => {
const findDuplicateQueryStateIdentifier = objectNameSingular;
const { objectMetadataItem, findDuplicateRecordsQuery } =
useObjectMetadataItem({ objectNameSingular }, depth);
const { enqueueSnackBar } = useSnackBar();
const { data, loading, error } = useQuery<ObjectRecordQueryResult<T>>(
findDuplicateRecordsQuery,
{
variables: {
id: objectRecordId,
},
onCompleted: (data) => {
onCompleted?.(data[objectMetadataItem.nameSingular]);
},
onError: (error) => {
logError(
`useFindDuplicateRecords for "${objectMetadataItem.nameSingular}" error : ` +
error,
);
enqueueSnackBar(
`Error during useFindDuplicateRecords for "${objectMetadataItem.nameSingular}", ${error.message}`,
{
variant: 'error',
},
);
},
},
);
const objectRecordConnection =
data?.[`${objectMetadataItem.nameSingular}Duplicates`];
const mapConnectionToRecords = useMapConnectionToRecords();
const records = useMemo(
() =>
mapConnectionToRecords({
objectRecordConnection,
objectNameSingular,
depth: 5,
}) as T[],
[mapConnectionToRecords, objectRecordConnection, objectNameSingular],
);
return {
objectMetadataItem,
records,
totalCount: objectRecordConnection?.totalCount || 0,
loading,
error,
queryStateIdentifier: findDuplicateQueryStateIdentifier,
};
};

View File

@ -0,0 +1,42 @@
import { gql } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateFindDuplicateRecordsQuery = () => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
return ({
objectMetadataItem,
depth,
}: {
objectMetadataItem: ObjectMetadataItem;
depth?: number;
}) => gql`
query FindDuplicate${capitalize(objectMetadataItem.nameSingular)}($id: ID) {
${objectMetadataItem.nameSingular}Duplicates(id: $id){
edges {
node {
id
${objectMetadataItem.fields
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
maxDepthForRelations: depth,
}),
)
.join('\n')}
}
cursor
}
pageInfo {
hasNextPage
startCursor
endCursor
}
totalCount
}
}
`;
};

View File

@ -13,7 +13,8 @@ import {
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordRelationFieldCardSection } from '@/object-record/record-relation-card/components/RecordRelationFieldCardSection';
import { RecordDuplicatesFieldCardSection } from '@/object-record/record-show/record-detail-section/components/RecordDuplicatesFieldCardSection';
import { RecordRelationFieldCardSection } from '@/object-record/record-show/record-detail-section/components/RecordRelationFieldCardSection';
import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierSelector';
@ -193,6 +194,10 @@ export const RecordShowContainer = ({
</FieldContext.Provider>
))}
</PropertyBox>
<RecordDuplicatesFieldCardSection
objectRecordId={objectRecordId}
objectNameSingular={objectNameSingular}
/>
{relationFieldMetadataItems
.filter((item) => {
const relationObjectMetadataItem = item.toRelationMetadata

View File

@ -0,0 +1,62 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import styled from '@emotion/styled';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
const StyledHeader = styled.header<{ isDropdownOpen?: boolean }>`
align-items: center;
display: flex;
height: 24px;
margin-bottom: ${({ theme }) => theme.spacing(2)};
padding: ${() => (useIsMobile() ? '0 12px' : 'unset')};
`;
const StyledTitle = styled.div`
align-items: flex-end;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledTitleLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledLink = styled(Link)`
color: ${({ theme }) => theme.font.color.light};
text-decoration: none;
font-size: ${({ theme }) => theme.font.size.sm};
:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
type RecordDetailSectionHeaderProps = {
title: string;
link?: { to: string; label: string };
rightAdornment?: React.ReactNode;
hideRightAdornmentOnMouseLeave?: boolean;
};
export const RecordDetailSectionHeader = ({
title,
link,
rightAdornment,
hideRightAdornmentOnMouseLeave = true,
}: RecordDetailSectionHeaderProps) => {
const [isHovered, setIsHovered] = useState(false);
return (
<StyledHeader
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<StyledTitle>
<StyledTitleLabel>{title}</StyledTitleLabel>
{link && <StyledLink to={link.to}>{link.label}</StyledLink>}
</StyledTitle>
{hideRightAdornmentOnMouseLeave && !isHovered! ? null : rightAdornment}
</StyledHeader>
);
};

View File

@ -0,0 +1,51 @@
import styled from '@emotion/styled';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useFindDuplicateRecords } from '@/object-record/hooks/useFindDuplicateRecords';
import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader';
import { Card } from '@/ui/layout/card/components/Card';
import { CardContent } from '@/ui/layout/card/components/CardContent';
import { Section } from '@/ui/layout/section/components/Section';
const StyledCardContent = styled(CardContent)`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
padding: ${({ theme }) => theme.spacing(3)};
`;
export const RecordDuplicatesFieldCardSection = ({
objectRecordId,
objectNameSingular,
}: {
objectRecordId: string;
objectNameSingular: string;
}) => {
const { records: duplicateRecords } = useFindDuplicateRecords({
objectRecordId,
objectNameSingular,
});
if (duplicateRecords.length === 0) {
return null;
}
return (
<Section>
<RecordDetailSectionHeader title="Duplicates" />
<Card>
{duplicateRecords.slice(0, 5).map((duplicateRecord, index) => (
<StyledCardContent
key={`${objectNameSingular}${duplicateRecord.id}`}
divider={index < duplicateRecords.length - 1}
>
<RecordChip
record={duplicateRecord}
objectNameSingular={objectNameSingular}
/>
</StyledCardContent>
))}
</Card>
</Section>
);
};

View File

@ -1,6 +1,5 @@
import { useCallback, useContext } from 'react';
import { Link } from 'react-router-dom';
import { css, useTheme } from '@emotion/react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import qs from 'qs';
import { useRecoilValue } from 'recoil';
@ -10,7 +9,8 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordRelationFieldCardContent } from '@/object-record/record-relation-card/components/RecordRelationFieldCardContent';
import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader';
import { RecordRelationFieldCardContent } from '@/object-record/record-show/record-detail-section/components/RecordRelationFieldCardContent';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { SingleEntitySelectMenuItemsWithSearch } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch';
@ -26,7 +26,6 @@ import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { Section } from '@/ui/layout/section/components/Section';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { FilterQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
@ -34,51 +33,6 @@ const StyledAddDropdown = styled(Dropdown)`
margin-left: auto;
`;
const StyledHeader = styled.header<{ isDropdownOpen?: boolean }>`
align-items: center;
display: flex;
margin-bottom: ${({ theme }) => theme.spacing(2)};
padding: ${() => (useIsMobile() ? '0 12px' : 'unset')};
${({ isDropdownOpen, theme }) =>
isDropdownOpen
? ''
: css`
.displayOnHover {
opacity: 0;
pointer-events: none;
transition: opacity ${theme.animation.duration.instant}s ease;
}
`}
&:hover {
.displayOnHover {
opacity: 1;
pointer-events: auto;
}
}
`;
const StyledTitle = styled.div`
align-items: flex-end;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledTitleLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledLink = styled(Link)`
color: ${({ theme }) => theme.font.color.light};
text-decoration: none;
font-size: ${({ theme }) => theme.font.size.sm};
:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
const StyledCardNoContent = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
@ -181,46 +135,50 @@ export const RecordRelationFieldCardSection = () => {
return (
<Section>
<StyledHeader isDropdownOpen={isDropdownOpen}>
<StyledTitle>
<StyledTitleLabel>{fieldDefinition.label}</StyledTitleLabel>
{isFromManyObjects && (
<StyledLink to={filterLinkHref}>
All ({relationRecords.length})
</StyledLink>
)}
</StyledTitle>
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={isToOneObject ? IconPencil : IconPlus}
accent="tertiary"
/>
}
dropdownComponents={
<RelationPickerScope relationPickerScopeId={dropdownId}>
<SingleEntitySelectMenuItemsWithSearch
EmptyIcon={IconForbid}
onEntitySelected={handleRelationPickerEntitySelected}
selectedRelationRecordIds={relationRecordIds}
relationObjectNameSingular={
relationObjectMetadataNameSingular
}
relationPickerScopeId={dropdownId}
<RecordDetailSectionHeader
title={fieldDefinition.label}
link={
isFromManyObjects
? {
to: filterLinkHref,
label: `All (${relationRecords.length})`,
}
: undefined
}
hideRightAdornmentOnMouseLeave={!isDropdownOpen}
rightAdornment={
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={isToOneObject ? IconPencil : IconPlus}
accent="tertiary"
/>
</RelationPickerScope>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
</StyledHeader>
}
dropdownComponents={
<RelationPickerScope relationPickerScopeId={dropdownId}>
<SingleEntitySelectMenuItemsWithSearch
EmptyIcon={IconForbid}
onEntitySelected={handleRelationPickerEntitySelected}
selectedRelationRecordIds={relationRecordIds}
relationObjectNameSingular={
relationObjectMetadataNameSingular
}
relationPickerScopeId={dropdownId}
/>
</RelationPickerScope>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
}
/>
{relationRecords.length === 0 && (
<StyledCardNoContent>
<Icon size={theme.icon.size.sm} />

View File

@ -14,7 +14,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { arrayToChunks } from '~/utils/array/array-to-chunks';
import { arrayToChunks } from '~/utils/array/arrayToChunks';
import { IconButton, IconButtonVariant } from '../button/components/IconButton';
import { LightIconButton } from '../button/components/LightIconButton';

View File

@ -3,7 +3,7 @@ import { ReactNode, useEffect } from 'react';
import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { SelectableListScope } from '@/ui/layout/selectable-list/scopes/SelectableListScope';
import { arrayToChunks } from '~/utils/array/array-to-chunks';
import { arrayToChunks } from '~/utils/array/arrayToChunks';
type SelectableListProps = {
children: ReactNode;

View File

@ -1,4 +1,4 @@
import { arrayToChunks } from '~/utils/array/array-to-chunks';
import { arrayToChunks } from '~/utils/array/arrayToChunks';
describe('arrayToChunks', () => {
it('should split an array into subarrays of a given size', () => {

View File

@ -0,0 +1,33 @@
import { moveArrayItem } from '~/utils/array/moveArrayItem';
describe('moveArrayItem', () => {
it('should return an empty array if provided with empty array', () => {
expect(moveArrayItem([], { fromIndex: 0, toIndex: 0 })).toEqual([]);
});
it('should return the same array if fromIndex is larger than array.length', () => {
expect(moveArrayItem([1, 2], { fromIndex: 3, toIndex: 0 })).toEqual([1, 2]);
});
it('should return the same array if toIndex is larger than array.length', () => {
expect(moveArrayItem([1, 2], { fromIndex: 0, toIndex: 3 })).toEqual([1, 2]);
});
it('should return the same array if fromIndex is smaller than 0', () => {
expect(moveArrayItem([1, 2], { fromIndex: -1, toIndex: 0 })).toEqual([
1, 2,
]);
});
it('should return the same array if toIndex is smaller than 0', () => {
expect(moveArrayItem([1, 2], { fromIndex: 1, toIndex: -1 })).toEqual([
1, 2,
]);
});
it('should move array items based on fromIndex and toIndex', () => {
expect(moveArrayItem([1, 2, 3], { fromIndex: 0, toIndex: 1 })).toEqual([
2, 1, 3,
]);
});
});

View File

@ -13,6 +13,8 @@ export const getResolverName = (
return `${camelCase(objectMetadata.namePlural)}`;
case 'findOne':
return `${camelCase(objectMetadata.nameSingular)}`;
case 'findDuplicates':
return `${camelCase(objectMetadata.nameSingular)}Duplicates`;
case 'createMany':
return `create${pascalCase(objectMetadata.namePlural)}`;
case 'createOne':

View File

@ -0,0 +1,175 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordFilter } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { FindDuplicatesResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ArgsAliasFactory } from 'src/workspace/workspace-query-builder/factories/args-alias.factory';
import { FieldsStringFactory } from 'src/workspace/workspace-query-builder/factories/fields-string.factory';
import { FindDuplicatesQueryFactory } from 'src/workspace/workspace-query-builder/factories/find-duplicates-query.factory';
import { workspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/utils-test/workspace-query-builder-options';
describe('FindDuplicatesQueryFactory', () => {
let service: FindDuplicatesQueryFactory;
const argAliasCreate = jest.fn();
beforeEach(async () => {
jest.resetAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
FindDuplicatesQueryFactory,
{
provide: FieldsStringFactory,
useValue: {
create: jest.fn().mockResolvedValue('fieldsString'),
// Mock implementation of FieldsStringFactory methods if needed
},
},
{
provide: ArgsAliasFactory,
useValue: {
create: argAliasCreate,
// Mock implementation of ArgsAliasFactory methods if needed
},
},
],
}).compile();
service = module.get<FindDuplicatesQueryFactory>(
FindDuplicatesQueryFactory,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should return (first: 0) as a filter when args are missing', async () => {
const args: FindDuplicatesResolverArgs<RecordFilter> = {};
const query = await service.create(args, workspaceQueryBuilderOptions);
expect(query.trim()).toEqual(`query {
objectNameCollection(first: 0) {
fieldsString
}
}`);
});
it('should use firstName and lastName as a filter when both args are present', async () => {
argAliasCreate.mockReturnValue({
nameFirstName: 'John',
nameLastName: 'Doe',
});
const args: FindDuplicatesResolverArgs<RecordFilter> = {
data: {
name: {
firstName: 'John',
lastName: 'Doe',
},
} as unknown as RecordFilter,
};
const query = await service.create(args, {
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
});
expect(query.trim()).toEqual(`query {
personCollection(filter: {or:[{nameFirstName:{ilike:\"%John%\"},nameLastName:{ilike:\"%Doe%\"}}]}) {
fieldsString
}
}`);
});
it('should return (first: 0) as a filter when only firstName is present', async () => {
argAliasCreate.mockReturnValue({
nameFirstName: 'John',
});
const args: FindDuplicatesResolverArgs<RecordFilter> = {
data: {
name: {
firstName: 'John',
},
} as unknown as RecordFilter,
};
const query = await service.create(args, {
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
});
expect(query.trim()).toEqual(`query {
personCollection(first: 0) {
fieldsString
}
}`);
});
it('should use "currentRecord" as query args when its present', async () => {
argAliasCreate.mockReturnValue({
nameFirstName: 'John',
});
const args: FindDuplicatesResolverArgs<RecordFilter> = {
id: 'uuid',
};
const query = await service.create(
args,
{
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
},
{
nameFirstName: 'Peter',
nameLastName: 'Parker',
},
);
expect(query.trim()).toEqual(`query {
personCollection(filter: {id:{neq:\"uuid\"},or:[{nameFirstName:{ilike:\"%Peter%\"},nameLastName:{ilike:\"%Parker%\"}}]}) {
fieldsString
}
}`);
});
});
describe('buildQueryForExistingRecord', () => {
it(`should include all the fields that exist for person inside "duplicateCriteriaCollection" constant`, async () => {
const query = service.buildQueryForExistingRecord('uuid', {
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
});
expect(query.trim()).toEqual(`query {
personCollection(filter: { id: { eq: \"uuid\" }}){
edges {
node {
__typename
nameFirstName
nameLastName
linkedinLinkUrl
email
}
}
}
}`);
});
});
});

View File

@ -10,6 +10,7 @@ import { FindOneQueryFactory } from './find-one-query.factory';
import { UpdateOneQueryFactory } from './update-one-query.factory';
import { UpdateManyQueryFactory } from './update-many-query.factory';
import { DeleteManyQueryFactory } from './delete-many-query.factory';
import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory';
export const workspaceQueryBuilderFactories = [
ArgsAliasFactory,
@ -21,6 +22,7 @@ export const workspaceQueryBuilderFactories = [
FieldsStringFactory,
FindManyQueryFactory,
FindOneQueryFactory,
FindDuplicatesQueryFactory,
UpdateOneQueryFactory,
UpdateManyQueryFactory,
DeleteManyQueryFactory,

View File

@ -0,0 +1,146 @@
import { Injectable, Logger } from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { RecordFilter } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { FindDuplicatesResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
import { stringifyWithoutKeyQuote } from 'src/workspace/workspace-query-builder/utils/stringify-without-key-quote.util';
import { ArgsAliasFactory } from 'src/workspace/workspace-query-builder/factories/args-alias.factory';
import { duplicateCriteriaCollection } from 'src/workspace/workspace-resolver-builder/constants/duplicate-criteria.constants';
import { FieldsStringFactory } from './fields-string.factory';
@Injectable()
export class FindDuplicatesQueryFactory {
private readonly logger = new Logger(FindDuplicatesQueryFactory.name);
constructor(
private readonly fieldsStringFactory: FieldsStringFactory,
private readonly argsAliasFactory: ArgsAliasFactory,
) {}
async create<Filter extends RecordFilter = RecordFilter>(
args: FindDuplicatesResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
currentRecord?: Record<string, unknown>,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
const argsData = this.getFindDuplicateBy<Filter>(
args,
options,
currentRecord,
);
const duplicateCondition = this.buildDuplicateCondition(
options.objectMetadataItem,
argsData,
args.id,
);
const filters = stringifyWithoutKeyQuote(duplicateCondition);
return `
query {
${computeObjectTargetTable(options.objectMetadataItem)}Collection${
isEmpty(duplicateCondition?.or)
? '(first: 0)'
: `(filter: ${filters})`
} {
${fieldsString}
}
}
`;
}
getFindDuplicateBy<Filter extends RecordFilter = RecordFilter>(
args: FindDuplicatesResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
currentRecord?: Record<string, unknown>,
) {
if (currentRecord) {
return currentRecord;
}
return this.argsAliasFactory.create(
args.data ?? {},
options.fieldMetadataCollection,
);
}
buildQueryForExistingRecord(
id: string,
options: WorkspaceQueryBuilderOptions,
) {
return `
query {
${computeObjectTargetTable(
options.objectMetadataItem,
)}Collection(filter: { id: { eq: "${id}" }}){
edges {
node {
__typename
${this.getApplicableDuplicateCriteriaCollection(
options.objectMetadataItem,
)
.flatMap((dc) => dc.columnNames)
.join('\n')}
}
}
}
}
`;
}
private buildDuplicateCondition(
objectMetadataItem: ObjectMetadataInterface,
argsData?: Record<string, unknown>,
filteringByExistingRecordId?: string,
) {
if (!argsData) {
return;
}
const criteriaCollection =
this.getApplicableDuplicateCriteriaCollection(objectMetadataItem);
const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) =>
criteria.columnNames.every((columnName) => !!argsData[columnName]),
);
const filterCriteria = criteriaWithMatchingArgs.map((criteria) =>
Object.fromEntries(
criteria.columnNames.map((columnName) => [
columnName,
{ ilike: `%${argsData[columnName]}%` },
]),
),
);
return {
// when filtering by an existing record, we need to filter that explicit record out
...(filteringByExistingRecordId && {
id: { neq: filteringByExistingRecordId },
}),
// keep condition as "or" to get results by more duplicate criteria
or: filterCriteria,
};
}
private getApplicableDuplicateCriteriaCollection(
objectMetadataItem: ObjectMetadataInterface,
) {
return duplicateCriteriaCollection.filter(
(duplicateCriteria) =>
duplicateCriteria.objectName === objectMetadataItem.nameSingular,
);
}
}

View File

@ -19,3 +19,8 @@ export enum OrderByDirection {
export type RecordOrderBy = {
[Property in keyof Record]?: OrderByDirection;
};
export interface RecordDuplicateCriteria {
objectName: string;
columnNames: string[];
}

View File

@ -0,0 +1,13 @@
import { GraphQLResolveInfo } from 'graphql';
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item';
export const workspaceQueryBuilderOptions: WorkspaceQueryBuilderOptions = {
fieldMetadataCollection: [],
info: {} as GraphQLResolveInfo,
objectMetadataCollection: [],
objectMetadataItem: objectMetadataItem as ObjectMetadataInterface,
};

View File

@ -14,6 +14,7 @@ import {
DeleteOneResolverArgs,
UpdateManyResolverArgs,
DeleteManyResolverArgs,
FindDuplicatesResolverArgs,
} from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { FindManyQueryFactory } from './factories/find-many-query.factory';
@ -29,6 +30,7 @@ import {
DeleteManyQueryFactory,
DeleteManyQueryFactoryOptions,
} from './factories/delete-many-query.factory';
import { FindDuplicatesQueryFactory } from './factories/find-duplicates-query.factory';
@Injectable()
export class WorkspaceQueryBuilderFactory {
@ -37,6 +39,7 @@ export class WorkspaceQueryBuilderFactory {
constructor(
private readonly findManyQueryFactory: FindManyQueryFactory,
private readonly findOneQueryFactory: FindOneQueryFactory,
private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory,
private readonly createManyQueryFactory: CreateManyQueryFactory,
private readonly updateOneQueryFactory: UpdateOneQueryFactory,
private readonly deleteOneQueryFactory: DeleteOneQueryFactory,
@ -61,6 +64,28 @@ export class WorkspaceQueryBuilderFactory {
return this.findOneQueryFactory.create<Filter>(args, options);
}
findDuplicates<Filter extends RecordFilter = RecordFilter>(
args: FindDuplicatesResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
existingRecord?: Record<string, unknown>,
): Promise<string> {
return this.findDuplicatesQueryFactory.create<Filter>(
args,
options,
existingRecord,
);
}
findDuplicatesExistingRecord(
id: string,
options: WorkspaceQueryBuilderOptions,
): string {
return this.findDuplicatesQueryFactory.buildQueryForExistingRecord(
id,
options,
);
}
createMany<Record extends IRecord = IRecord>(
args: CreateManyResolverArgs<Record>,
options: WorkspaceQueryBuilderOptions,

View File

@ -3,6 +3,7 @@ import {
CreateOneResolverArgs,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
UpdateManyResolverArgs,
@ -16,6 +17,7 @@ export type ExecutePreHookMethod =
| 'deleteOne'
| 'findMany'
| 'findOne'
| 'findDuplicates'
| 'updateMany'
| 'updateOne';
@ -45,4 +47,6 @@ export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
? UpdateManyResolverArgs
: T extends 'updateOne'
? UpdateOneResolverArgs
: never;
: T extends 'findDuplicates'
? FindDuplicatesResolverArgs
: never;

View File

@ -6,6 +6,8 @@ import {
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import isEmpty from 'lodash.isempty';
import { IConnection } from 'src/utils/pagination/interfaces/connection.interface';
import {
Record as IRecord,
@ -17,6 +19,7 @@ import {
CreateOneResolverArgs,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
UpdateManyResolverArgs,
@ -40,6 +43,7 @@ import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/ob
import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event';
import { WorkspacePreQueryHookService } from 'src/workspace/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { NotFoundError } from 'src/filters/utils/graphql-errors.util';
import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface';
import {
@ -136,6 +140,74 @@ export class WorkspaceQueryRunnerService {
return parsedResult?.edges?.[0]?.node;
}
async findDuplicates<TRecord extends IRecord = IRecord>(
args: FindDuplicatesResolverArgs<TRecord>,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<TRecord> | undefined> {
if (!args.data && !args.id) {
throw new BadRequestException(
'You have to provide either "data" or "id" argument',
);
}
if (!args.id && isEmpty(args.data)) {
throw new BadRequestException(
'The "data" condition can not be empty when ID input not provided',
);
}
const { workspaceId, userId, objectMetadataItem } = options;
let existingRecord: Record<string, unknown> | undefined;
if (args.id) {
const existingRecordQuery =
this.workspaceQueryBuilderFactory.findDuplicatesExistingRecord(
args.id,
options,
);
const existingRecordResult = await this.execute(
existingRecordQuery,
workspaceId,
);
const parsedResult = this.parseResult<Record<string, unknown>>(
existingRecordResult,
objectMetadataItem,
'',
);
existingRecord = parsedResult?.edges?.[0]?.node;
if (!existingRecord) {
throw new NotFoundError(`Object with id ${args.id} not found`);
}
}
const query = await this.workspaceQueryBuilderFactory.findDuplicates(
args,
options,
existingRecord,
);
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'findDuplicates',
args,
);
const result = await this.execute(query, workspaceId);
return this.parseResult<IConnection<TRecord>>(
result,
objectMetadataItem,
'',
);
}
async createMany<Record extends IRecord = IRecord>(
args: CreateManyResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,

View File

@ -0,0 +1,30 @@
import { RecordDuplicateCriteria } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
/**
* objectName: directly reference the name of the object from the metadata tables.
* columnNames: reference the column names not the field names.
* So if we need to reference a custom field, we should directly add the column name like `_customColumn`.
* If we need to terence a composite field, we should add all children of the composite like `nameFirstName` and `nameLastName`
*/
export const duplicateCriteriaCollection: RecordDuplicateCriteria[] = [
{
objectName: 'company',
columnNames: ['domainName'],
},
{
objectName: 'company',
columnNames: ['name'],
},
{
objectName: 'person',
columnNames: ['nameFirstName', 'nameLastName'],
},
{
objectName: 'person',
columnNames: ['linkedinLinkUrl'],
},
{
objectName: 'person',
columnNames: ['email'],
},
];

View File

@ -1,5 +1,6 @@
import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/update-many-resolver.factory';
import { FindDuplicatesResolverFactory } from './find-duplicates-resolver.factory';
import { FindManyResolverFactory } from './find-many-resolver.factory';
import { FindOneResolverFactory } from './find-one-resolver.factory';
import { CreateManyResolverFactory } from './create-many-resolver.factory';
@ -12,6 +13,7 @@ import { ExecuteQuickActionOnOneResolverFactory } from './execute-quick-action-o
export const workspaceResolverBuilderFactories = [
FindManyResolverFactory,
FindOneResolverFactory,
FindDuplicatesResolverFactory,
CreateManyResolverFactory,
CreateOneResolverFactory,
UpdateOneResolverFactory,
@ -25,6 +27,7 @@ export const workspaceResolverBuilderMethodNames = {
queries: [
FindManyResolverFactory.methodName,
FindOneResolverFactory.methodName,
FindDuplicatesResolverFactory.methodName,
],
mutations: [
CreateManyResolverFactory.methodName,

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
FindDuplicatesResolverArgs,
Resolver,
} from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/workspace/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/workspace/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class FindDuplicatesResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'findDuplicates' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<FindDuplicatesResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.findDuplicates(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -26,6 +26,11 @@ export interface FindOneResolverArgs<Filter = any> {
filter?: Filter;
}
export interface FindDuplicatesResolverArgs<Data extends Record = Record> {
id?: string;
data?: Data;
}
export interface CreateOneResolverArgs<Data extends Record = Record> {
data: Data;
}
@ -81,5 +86,6 @@ export type ResolverArgs =
| DeleteOneResolverArgs
| FindManyResolverArgs
| FindOneResolverArgs
| FindDuplicatesResolverArgs
| UpdateManyResolverArgs
| UpdateOneResolverArgs;

View File

@ -9,6 +9,7 @@ import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-buil
import { DeleteManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/delete-many-resolver.factory';
import { ExecuteQuickActionOnOneResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/execute-quick-action-on-one-resolver.factory';
import { FindDuplicatesResolverFactory } from './factories/find-duplicates-resolver.factory';
import { FindManyResolverFactory } from './factories/find-many-resolver.factory';
import { FindOneResolverFactory } from './factories/find-one-resolver.factory';
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
@ -28,6 +29,7 @@ export class WorkspaceResolverFactory {
constructor(
private readonly findManyResolverFactory: FindManyResolverFactory,
private readonly findOneResolverFactory: FindOneResolverFactory,
private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory,
private readonly createManyResolverFactory: CreateManyResolverFactory,
private readonly createOneResolverFactory: CreateOneResolverFactory,
private readonly updateOneResolverFactory: UpdateOneResolverFactory,
@ -49,6 +51,7 @@ export class WorkspaceResolverFactory {
>([
['findMany', this.findManyResolverFactory],
['findOne', this.findOneResolverFactory],
['findDuplicates', this.findDuplicatesResolverFactory],
['createMany', this.createManyResolverFactory],
['createOne', this.createOneResolverFactory],
['updateOne', this.updateOneResolverFactory],

View File

@ -74,7 +74,7 @@ export class RootTypeFactory {
const args = getResolverArgs(methodName);
const objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id,
methodName === 'findMany'
['findMany', 'findDuplicates'].includes(methodName)
? ObjectTypeDefinitionKind.Connection
: ObjectTypeDefinitionKind.Plain,
);

View File

@ -69,6 +69,17 @@ export const getResolverArgs = (
isNullable: false,
},
};
case 'findDuplicates':
return {
id: {
type: FieldMetadataType.UUID,
isNullable: true,
},
data: {
kind: InputTypeDefinitionKind.Create,
isNullable: true,
},
};
case 'deleteOne':
return {
id: {