Files
twenty/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts
T

738 lines
23 KiB
TypeScript

import { randomUUID } from 'crypto';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { createViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/create-view-filter-group-operation-factory.util';
import { createViewOperationFactory } from 'test/integration/graphql/utils/create-view-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { createOneCoreViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-core-view-filter.util';
import { jestExpectToBeDefined } from 'test/utils/expect-to-be-defined.util.test';
import { ViewFilterOperand } from 'twenty-shared/types';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ViewFilterGroupLogicalOperator } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity';
describe('group-by resolvers (integration)', () => {
describe('standard case', () => {
const testPersonId = randomUUID();
const testPerson2Id = randomUUID();
const testPerson3Id = randomUUID();
afterEach(async () => {
// cleanup created people
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: testPersonId,
}),
);
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: testPerson2Id,
}),
);
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: testPerson3Id,
}),
);
});
it('groups by city', async () => {
const cityA = 'City A';
const cityB = 'City B';
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPersonId, city: cityA },
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPerson2Id, city: cityB },
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPerson3Id, city: cityB },
}),
);
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ city: true }],
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(groups).toEqual(
expect.arrayContaining([
expect.objectContaining({ groupByDimensionValues: [cityA] }),
expect.objectContaining({ groupByDimensionValues: [cityB] }),
]),
);
const groupWithCityA = groups.find(
(group: any) => group.groupByDimensionValues[0] === cityA,
);
expect(groupWithCityA.groupByDimensionValues).toEqual([cityA]);
expect(groupWithCityA.totalCount).toEqual(1);
const groupWithCityB = groups.find(
(group: any) => group.groupByDimensionValues[0] === cityB,
);
expect(groupWithCityB.groupByDimensionValues).toEqual([cityB]);
expect(groupWithCityB.totalCount).toEqual(2);
});
});
describe('date range', () => {
const testPersonId = randomUUID();
const testPerson2Id = randomUUID();
const testPerson3Id = randomUUID();
beforeAll(async () => {
const idJan2 = testPersonId;
const idJan8 = testPerson2Id;
const idMar3 = testPerson3Id;
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: idJan2,
createdAt: '2025-01-02T12:00:00.000Z', // thursday, january, Q1, 2025
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: idJan8, createdAt: '2025-01-08T08:00:00.000Z' }, // wednesday, january, Q1, 2025
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: idMar3, createdAt: '2025-03-03T09:30:00.000Z' }, // monday, march, Q1, 2025
}),
);
});
afterAll(async () => {
// cleanup created people
for (const id of [testPersonId, testPerson2Id, testPerson3Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: id,
}),
);
}
});
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
it('datetime field - groups by createdAt MONTH', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ createdAt: { granularity: 'MONTH' } }],
filter: filter2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
// Expect two groups: January 2025 and March 2025
const janGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('2025-01-01'),
);
const marGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('2025-03-01'),
);
expect(janGroup).toBeDefined();
expect(marGroup).toBeDefined();
expect(janGroup.totalCount).toBe(2);
expect(marGroup.totalCount).toBe(1);
});
describe('cyclic date', () => {
const filter2024And2025 = {
and: [
{
createdAt: {
gte: '2024-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
const testPersonId2024 = randomUUID();
beforeAll(async () => {
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPersonId2024,
createdAt: '2024-04-11T12:00:00.000Z', // thursday, april, Q2, 2024
},
}),
);
});
afterAll(async () => {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: testPersonId2024,
}),
);
});
it('datetime field - groups by createdAt DAY_OF_THE_WEEK', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ createdAt: { granularity: 'DAY_OF_THE_WEEK' } }],
// adding a filter for test not to fail when we are in january again
filter: filter2024And2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const thursdayGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Thursday'),
);
const mondayGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Monday'),
);
const wednesdayGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday'),
);
expect(thursdayGroup).toBeDefined();
expect(mondayGroup).toBeDefined();
expect(wednesdayGroup).toBeDefined();
expect(thursdayGroup.totalCount).toBe(2);
expect(mondayGroup.totalCount).toBe(1);
expect(wednesdayGroup.totalCount).toBe(1);
});
it('datetime field - groups by createdAt MONTH_OF_THE_YEAR', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ createdAt: { granularity: 'MONTH_OF_THE_YEAR' } }],
filter: filter2024And2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const janGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('January'),
);
const marGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('March'),
);
const aprGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('April'),
);
expect(janGroup).toBeDefined();
expect(marGroup).toBeDefined();
expect(aprGroup).toBeDefined();
expect(janGroup.totalCount).toBe(2);
expect(marGroup.totalCount).toBe(1);
expect(aprGroup.totalCount).toBe(1);
});
it('datetime field - groups by createdAt QUARTER_OF_THE_YEAR', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ createdAt: { granularity: 'QUARTER_OF_THE_YEAR' } }],
filter: filter2024And2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const q1Group = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Q1'),
);
const q2Group = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Q2'),
);
expect(q1Group).toBeDefined();
expect(q2Group).toBeDefined();
expect(q1Group.totalCount).toBe(3);
expect(q2Group.totalCount).toBe(1);
});
});
});
describe('with viewId defined', () => {
const testPersonId = randomUUID();
const testPerson2Id = randomUUID();
const testPerson3Id = randomUUID();
let viewId: string;
let personObjectMetadataId: string;
let personObject: ObjectMetadataDTO & {
fieldsList?: FieldMetadataDTO[];
};
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: {
first: 100,
},
},
gqlFields: 'id nameSingular fieldsList { id name }',
expectToFail: false,
});
const person = objects.find((o) => o.nameSingular === 'person');
if (!person || !person.id) {
throw new Error('Person object not found');
}
personObject = person;
personObjectMetadataId = personObject.id;
});
afterEach(async () => {
// cleanup created people
for (const id of [testPersonId, testPerson2Id, testPerson3Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: id,
}),
);
}
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'view',
gqlFields: 'id',
recordId: viewId,
}),
);
});
it('groups by city', async () => {
const cityFieldMetadata = personObject?.fieldsList?.find(
(f: FieldMetadataDTO) => f.name === 'city',
);
const cityFieldMetadataId = cityFieldMetadata?.id;
const cityToKeep = 'City To Keep';
const cityToExclude = 'City To Exclude';
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPersonId, city: cityToKeep },
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPerson2Id, city: cityToExclude },
}),
);
// create a view with a filter: city eq cityToKeep
const createViewResponse = await makeGraphqlAPIRequest(
createViewOperationFactory({
data: {
name: 'People View City Keep',
objectMetadataId: personObjectMetadataId,
icon: 'Icon123',
},
}),
);
viewId = createViewResponse.body.data.createCoreView.id as string;
// create a filter group and a filter for the view
const viewFilterGroupResponse = await makeGraphqlAPIRequest(
createViewFilterGroupOperationFactory({
data: {
viewId,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
positionInViewFilterGroup: 0,
},
}),
);
const viewFilterGroupId = viewFilterGroupResponse.body.data
.createCoreViewFilterGroup.id as string;
jestExpectToBeDefined(cityFieldMetadataId);
await createOneCoreViewFilter({
input: {
viewId,
viewFilterGroupId,
fieldMetadataId: cityFieldMetadataId,
operand: ViewFilterOperand.CONTAINS,
value: cityToKeep,
positionInViewFilterGroup: 0,
},
expectToFail: false,
});
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ city: true }],
viewId,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(groups).toEqual(
expect.arrayContaining([
expect.objectContaining({ groupByDimensionValues: [cityToKeep] }),
]),
);
// Ensure excluded city is not present
expect(groups).toEqual(
expect.not.arrayContaining([
expect.objectContaining({ groupByDimensionValues: [cityToExclude] }),
]),
);
});
it('groups by city with any field filter', async () => {
const cityA = 'City A';
const cityB = 'City B';
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPersonId, city: cityA },
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPerson2Id, city: cityB },
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPerson3Id, city: cityB },
}),
);
// create a view with any field filter
const createViewResponse = await makeGraphqlAPIRequest(
createViewOperationFactory({
data: {
name: 'People View City Keep',
objectMetadataId: personObjectMetadataId,
icon: 'Icon123',
anyFieldFilterValue: cityA,
},
}),
);
viewId = createViewResponse.body.data.createCoreView.id as string;
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ city: true }],
viewId,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(groups).toEqual(
expect.arrayContaining([
expect.objectContaining({ groupByDimensionValues: [cityA] }),
]),
);
// Ensure excluded city is not present
expect(groups).toEqual(
expect.not.arrayContaining([
expect.objectContaining({ groupByDimensionValues: [cityB] }),
]),
);
});
});
describe('omitNullValues', () => {
describe('numeric aggregates', () => {
const zeroGroupCompanyId1 = randomUUID();
const zeroGroupCompanyId2 = randomUUID();
const positiveGroupCompanyId = randomUUID();
const zeroCity = 'ZeroCity';
const positiveCity = 'PositiveCity';
beforeAll(async () => {
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: zeroGroupCompanyId1,
name: 'Zero City One',
address: { addressCity: zeroCity },
employees: 0,
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: zeroGroupCompanyId2,
name: 'Zero City Two',
address: { addressCity: zeroCity },
employees: 0,
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: positiveGroupCompanyId,
name: 'Positive City One',
address: { addressCity: positiveCity },
employees: 8,
},
}),
);
});
afterAll(async () => {
for (const id of [
zeroGroupCompanyId1,
zeroGroupCompanyId2,
positiveGroupCompanyId,
]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('filters out groups with zero numeric aggregates when enabled', async () => {
const baseOperation = {
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
groupBy: [{ address: { addressCity: true } }],
gqlFields: `
sumEmployees
`,
};
const responseWithoutFilter = await makeGraphqlAPIRequest(
groupByOperationFactory(baseOperation),
);
expect(responseWithoutFilter.body.errors).toBeUndefined();
const groupsWithoutFilter =
responseWithoutFilter.body.data.companiesGroupBy;
const zeroAggregateGroup = groupsWithoutFilter.find(
(group: any) => group.groupByDimensionValues?.[0] === zeroCity,
);
expect(zeroAggregateGroup).toBeDefined();
expect(zeroAggregateGroup.sumEmployees).toBe(0);
const responseWithFilter = await makeGraphqlAPIRequest(
groupByOperationFactory({
...baseOperation,
omitNullValues: true,
}),
);
expect(responseWithFilter.body.errors).toBeUndefined();
const groupsWithFilter = responseWithFilter.body.data.companiesGroupBy;
expect(
groupsWithFilter.find(
(group: any) => group.groupByDimensionValues?.[0] === zeroCity,
),
).toBeUndefined();
const positiveAggregateGroup = groupsWithFilter.find(
(group: any) => group.groupByDimensionValues?.[0] === positiveCity,
);
expect(positiveAggregateGroup).toBeDefined();
expect(positiveAggregateGroup.sumEmployees).toBeGreaterThan(0);
});
});
describe('non-numeric aggregates', () => {
const dateCityPersonId1 = randomUUID();
const dateCityPersonId2 = randomUUID();
const dateCity = 'DateCity';
beforeAll(async () => {
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: dateCityPersonId1,
name: { firstName: 'Date', lastName: 'One' },
city: dateCity,
createdAt: '2025-04-01T00:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: dateCityPersonId2,
name: { firstName: 'Date', lastName: 'Two' },
city: dateCity,
createdAt: '2025-04-02T00:00:00.000Z',
},
}),
);
});
afterAll(async () => {
for (const id of [dateCityPersonId1, dateCityPersonId2]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('does not throw error for date aggregates when enabled', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ city: true }],
gqlFields: `
minCreatedAt
`,
omitNullValues: true,
}),
);
expect(response.body.errors).toBeUndefined();
const groups = response.body.data.peopleGroupBy;
const dateCityGroup = groups.find(
(group: any) => group.groupByDimensionValues?.[0] === dateCity,
);
expect(dateCityGroup).toBeDefined();
expect(dateCityGroup.minCreatedAt).toBe('2025-04-01T00:00:00.000Z');
});
});
});
});