[Rest Api] Fix find duplicates endpoint (#12044)

- fix endpoint
- migrate to new rest api v2 service
- add integration test
This commit is contained in:
martmull
2025-05-14 22:03:59 +02:00
committed by GitHub
parent fdc7d6c93c
commit 81cc5da982
19 changed files with 722 additions and 311 deletions
@@ -1,142 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { computeCursorArgFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter';
describe('computeCursorArgFilter', () => {
const mockFieldMetadataMap = {
name: {
type: FieldMetadataType.TEXT,
id: 'name-id',
name: 'name',
label: 'Name',
objectMetadataId: 'object-id',
},
age: {
type: FieldMetadataType.NUMBER,
id: 'age-id',
name: 'age',
label: 'Age',
objectMetadataId: 'object-id',
},
fullName: {
type: FieldMetadataType.FULL_NAME,
id: 'fullname-id',
name: 'fullName',
label: 'Full Name',
objectMetadataId: 'object-id',
},
};
describe('basic cursor filtering', () => {
it('should return empty array when cursor is empty', () => {
const result = computeCursorArgFilter({}, [], mockFieldMetadataMap, true);
expect(result).toEqual([]);
});
it('should compute forward pagination filter for single field', () => {
const cursor = { name: 'John' };
const orderBy = [{ name: OrderByDirection.AscNullsLast }];
const result = computeCursorArgFilter(
cursor,
orderBy,
mockFieldMetadataMap,
true,
);
expect(result).toEqual([{ name: { gt: 'John' } }]);
});
it('should compute backward pagination filter for single field', () => {
const cursor = { name: 'John' };
const orderBy = [{ name: OrderByDirection.AscNullsLast }];
const result = computeCursorArgFilter(
cursor,
orderBy,
mockFieldMetadataMap,
false,
);
expect(result).toEqual([{ name: { lt: 'John' } }]);
});
});
describe('multiple fields cursor filtering', () => {
it('should handle multiple cursor fields with forward pagination', () => {
const cursor = { name: 'John', age: 30 };
const orderBy = [
{ name: OrderByDirection.AscNullsLast },
{ age: OrderByDirection.DescNullsLast },
];
const result = computeCursorArgFilter(
cursor,
orderBy,
mockFieldMetadataMap,
true,
);
expect(result).toEqual([
{ name: { gt: 'John' } },
{ name: { eq: 'John' }, age: { lt: 30 } },
]);
});
});
describe('composite field handling', () => {
it('should handle fullName composite field', () => {
const cursor = {
fullName: { firstName: 'John', lastName: 'Doe' },
};
const orderBy = [
{
fullName: {
firstName: OrderByDirection.AscNullsLast,
lastName: OrderByDirection.AscNullsLast,
},
},
];
const result = computeCursorArgFilter(
cursor,
orderBy,
mockFieldMetadataMap,
true,
);
expect(result).toEqual([
{
fullName: {
firstName: { gt: 'John' },
lastName: { gt: 'Doe' },
},
},
]);
});
});
describe('error handling', () => {
it('should throw error for invalid field metadata', () => {
const cursor = { invalidField: 'value' };
const orderBy = [{ invalidField: OrderByDirection.AscNullsLast }];
expect(() =>
computeCursorArgFilter(cursor, orderBy, mockFieldMetadataMap, true),
).toThrow(GraphqlQueryRunnerException);
});
it('should throw error for missing orderBy entry', () => {
const cursor = { name: 'John' };
const orderBy = [{ age: OrderByDirection.AscNullsLast }];
expect(() =>
computeCursorArgFilter(cursor, orderBy, mockFieldMetadataMap, true),
).toThrow(GraphqlQueryRunnerException);
});
});
});
@@ -1,183 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
ObjectRecordFilter,
ObjectRecordOrderBy,
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
const computeOperator = (
isAscending: boolean,
isForwardPagination: boolean,
defaultOperator?: string,
): string => {
if (defaultOperator) return defaultOperator;
return isAscending
? isForwardPagination
? 'gt'
: 'lt'
: isForwardPagination
? 'lt'
: 'gt';
};
const validateAndGetOrderBy = (
key: string,
orderBy: ObjectRecordOrderBy,
): Record<string, any> => {
const keyOrderBy = orderBy.find((order) => key in order);
if (!keyOrderBy) {
throw new GraphqlQueryRunnerException(
'Invalid cursor',
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
);
}
return keyOrderBy;
};
const isAscendingOrder = (direction: OrderByDirection): boolean =>
direction === OrderByDirection.AscNullsFirst ||
direction === OrderByDirection.AscNullsLast;
export const computeCursorArgFilter = (
cursor: Record<string, any>,
orderBy: ObjectRecordOrderBy,
fieldMetadataMapByName: FieldMetadataMap,
isForwardPagination = true,
): ObjectRecordFilter[] => {
const cursorKeys = Object.keys(cursor ?? {});
const cursorValues = Object.values(cursor ?? {});
if (cursorKeys.length === 0) {
return [];
}
return Object.entries(cursor ?? {}).map(([key, value], index) => {
let whereCondition = {};
for (
let subConditionIndex = 0;
subConditionIndex < index;
subConditionIndex++
) {
whereCondition = {
...whereCondition,
...buildWhereCondition(
cursorKeys[subConditionIndex],
cursorValues[subConditionIndex],
fieldMetadataMapByName,
orderBy,
isForwardPagination,
'eq',
),
};
}
return {
...whereCondition,
...buildWhereCondition(
key,
value,
fieldMetadataMapByName,
orderBy,
isForwardPagination,
),
} as ObjectRecordFilter;
});
};
const buildWhereCondition = (
key: string,
value: any,
fieldMetadataMapByName: FieldMetadataMap,
orderBy: ObjectRecordOrderBy,
isForwardPagination: boolean,
operator?: string,
): Record<string, any> => {
const fieldMetadata = fieldMetadataMapByName[key];
if (!fieldMetadata) {
throw new GraphqlQueryRunnerException(
`Field metadata not found for key: ${key}`,
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
);
}
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
return buildCompositeWhereCondition(
key,
value,
fieldMetadata.type,
orderBy,
isForwardPagination,
operator,
);
}
const keyOrderBy = validateAndGetOrderBy(key, orderBy);
const isAscending = isAscendingOrder(keyOrderBy[key]);
const computedOperator = computeOperator(
isAscending,
isForwardPagination,
operator,
);
return { [key]: { [computedOperator]: value } };
};
const buildCompositeWhereCondition = (
key: string,
value: any,
fieldType: FieldMetadataType,
orderBy: ObjectRecordOrderBy,
isForwardPagination: boolean,
operator?: string,
): Record<string, any> => {
const compositeType = compositeTypeDefinitions.get(fieldType);
if (!compositeType) {
throw new GraphqlQueryRunnerException(
`Composite type definition not found for type: ${fieldType}`,
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
);
}
const keyOrderBy = validateAndGetOrderBy(key, orderBy);
const result: Record<string, any> = {};
compositeType.properties.forEach((property) => {
if (
property.type === FieldMetadataType.RAW_JSON ||
value[property.name] === undefined
) {
return;
}
const isAscending = isAscendingOrder(keyOrderBy[key][property.name]);
const computedOperator = computeOperator(
isAscending,
isForwardPagination,
operator,
);
result[key] = {
...result[key],
[property.name]: {
[computedOperator]: value[property.name],
},
};
});
return result;
};