Files
twenty/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts
T
Lucas Bordeau 0b5be7caa3 Refactored Date to Temporal in critical date zones (#16544)
Fixes https://github.com/twentyhq/twenty/issues/16110

This PR implements Temporal to replace the legacy Date object, in all
features that are time zone sensitive. (around 80% of the app)

Here we define a few utils to handle Temporal primitives and obtain an
easier DX for timezone manipulation, front end and back end.

This PR deactivates the usage of timezone from the graph configuration,
because for now it's always UTC and is not really relevant, let's handle
that later.

Workflows code and backend only code that don't take user input are
using UTC time zone, the affected utils have not been refactored yet
because this PR is big enough.

# New way of filtering on date intervals

As we'll progressively rollup Temporal everywhere in the codebase and
remove `Date` JS object everywhere possible, we'll use the way to filter
that is recommended by Temporal.

This way of filtering on date intervals involves half-open intervals,
and is the preferred way to avoid edge-cases with DST and smallest time
increment edge-case.

## Filtering endOfX with DST edge-cases

Some day-light save time shifts involve having no existing hour, or even
day on certain days, for example Samoa Islands have no 30th of December
2011 : https://www.timeanddate.com/news/time/samoa-dateline.html, it
jumps from 29th to 31st, so filtering on `< next period start` makes it
easier to let the date library handle the strict inferior comparison,
than filtering on `≤ end of period` and trying to compute manually the
end of the period.

For example for Samoa Islands, is end of day `2011-12-29T23:59:59.999`
or is it `2011-12-30T23:59:59.999` ? If you say I don't need to know and
compute it, because I want everything strictly before
`2011-12-29T00:00:00 + start of next day (according to the library which
knows those edge-cases)`, then you have a 100% deterministic way of
computing date intervals in any timezone, for any day of any year.

Of course the Samoa example is an extreme one, but more common ones
involve DST shifts of 1 hour, which are still problematic on certain
days of the year.

## Computing the exact _end of period_

Having an open interval filtering, with `[included - included]` instead
of half-open `[included - excluded)`, forces to compute the open end of
an interval, which often involves taking an arbitrary unit like minute,
second, microsecond or nanosecond, which will lead to edge-case of
unhandled values.

For example, let's say my code computes endOfDay by setting the time to
`23:59:59.999`, if another library, API, or anything else, ends up
giving me a date-time with another time precision `23:59:59.999999999`
(down to the nanosecond), then this date-time will be filtered out,
while it should not.

The good deterministic way to avoid 100% of those complex bugs is to
create a half-open filter :

`≥ start of period` to `< start of next period` 

For example : 

`≥ 2025-01-01T00:00:00` to `< 2025-01-02T00:00:00` instead of `≥
2025-01-01T00:00:00` to `≤ 2025-01-01T23:59:59.999`

Because, `2025-01-01T00:00:00` = `2025-01-01T00:00:00.000` =
`2025-01-01T00:00:00.000000` = `2025-01-01T00:00:00.000000000` => no
risk of error in computing start of period

But `2025-01-01T23:59:59` ≠ `2025-01-01T23:59:59.999` ≠
`2025-01-01T23:59:59.999999` ≠ `2025-01-01T23:59:59.999999999` =>
existing risk of error in computing end of period

This is why an half-open interval has no risk of error in computing a
date-time interval filter.

Here is a link to this debate :
https://github.com/tc39/proposal-temporal/issues/2568

> For this reason, we recommend not calculating the exact nanosecond at
the end of the day if it's not absolutely necessary. For example, if
it's needed for <= comparisons, we recommend just changing the
comparison code. So instead of <= zdtEndOfDay your code could be <
zdtStartOfNextDay which is easier to calculate and not subject to the
issue of not knowing which unit is the right one.
>
> [Justin Grant](https://github.com/justingrant), top contributor of
Temporal

## Application to our codebase

Applying this half-open filtering paradigm to our codebase means we
would have to rename `IS_AFTER` to `IS_AFTER_OR_EQUAL` and to keep
`IS_BEFORE` (or even `IS_STRICTLY_BEFORE`) to make this half-open
interval self-explanatory everywhere in the codebase, this will avoid
any confusion.

See the relevant issue :
https://github.com/twentyhq/core-team-issues/issues/2010

In the mean time, we'll keep this operand and add this semantic in the
naming everywhere possible.

## Example with a different user timezone 

Example on a graph grouped by week in timezone Pacific/Samoa, on a
computer running on Europe/Paris :

<img width="342" height="511" alt="image"
src="https://github.com/user-attachments/assets/9e7d5121-ecc4-4233-835b-f59293fbd8c8"
/>

Then the associated data in the table view, with our **half-open
date-time filter** :

<img width="804" height="262" alt="image"
src="https://github.com/user-attachments/assets/28efe1d7-d2fc-4aec-b521-bada7f980447"
/>

And the associated SQL query result to see how DATE_TRUNC in Postgres
applies its internal start of week logic :

<img width="709" height="220" alt="image"
src="https://github.com/user-attachments/assets/4d0542e1-eaae-4b4b-afa9-5005f48ffdca"
/>

The associated SQL query without parameters to test in your SQL client :

```SQL
SELECT "opportunity"."closeDate" as "close_date", TO_CHAR(DATE_TRUNC('week', "opportunity"."closeDate", 'Pacific/Samoa') AT TIME ZONE 'Pacific/Samoa', 'YYYY-MM-DD') AS "DATE_TRUNC by week start in timezone Pacific/Samoa", "opportunity"."name" FROM "workspace_1wgvd1injqtife6y4rvfbu3h5"."opportunity" "opportunity" ORDER BY "opportunity"."closeDate" ASC NULLS LAST
```

# Date picker simplification (not in this PR)

Our DatePicker component, which is wrapping `react-datepicker` library
component, is now exposing plain dates as string instead of Date object.
The Date object is still used internally to manage the library
component, but since the date picker calendar is only manipulating plain
dates, there is no need to add timezone management to it, and no need to
expose a handleChange with Date object.

The timezone management relies on date time inputs now.

The modification has been made in a previous PR :
https://github.com/twentyhq/twenty/issues/15377 but it's good to
reference it here.

# Calendar feature refactor

Calendar feature has been refactored to rely on Temporal.PlainDate as
much as possible, while leaving some date-fns utils to avoid re-coding
them.

Since the trick is to use utils to convert back and from Date object in
exec env reliably, we can do it everywhere we need to interface legacy
Date object utils and Temporal related code.

## TimeZone is now shown on Calendar :

<img width="894" height="958" alt="image"
src="https://github.com/user-attachments/assets/231f8107-fad6-4786-b532-456692c20f1d"
/>

## Month picker has been refactored 

<img width="503" height="266" alt="image"
src="https://github.com/user-attachments/assets/cb90bc34-6c4d-436d-93bc-4b6fb00de7f5"
/>

Since the days weren't useful, the picker has been refactored to remove
the days.

# Miscellaneous 
- Fixed a bug with drag and drop edge-case with 2 items in a list.

# Improvements 

## Lots of chained operations
It would be nice to create small utils to avoid repeated chained
operations, but that is how Temporal is designed, a very small set of
primitive operations that allow to compose everything needed. Maybe
we'll have wrappers on top of Temporal in the coming years.

## Creation of Temporal objects is throwing errors

If the input is badly formatted Temporal will throw, we might want to
adopt a global strategy to avoid that.

Example : 

```ts
const newPlainDate = Temporal.PlainDate.from('bad-string'); // Will throw
```
2025-12-23 17:40:26 +00:00

1894 lines
59 KiB
TypeScript

import { randomUUID } from 'crypto';
import { gql } from 'apollo-server-core';
import { default as request } from 'supertest';
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 { deleteRole } from 'test/integration/graphql/utils/delete-one-role.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 { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { createOneCoreViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-core-view-filter.util';
import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
FieldMetadataType,
OrderByDirection,
RelationType,
ViewFilterOperand,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
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 { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
const client = request(`http://localhost:${APP_PORT}`);
describe('group-by resolver (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 }],
orderBy: [{ city: OrderByDirection.AscNullsFirst }], // needed for City groups to be in 300 first groups
limit: 300,
}),
);
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);
});
it('limits the number of groups when limit argument is provided', async () => {
const cityA = 'City A';
const cityB = 'City B';
const cityC = 'City C';
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: cityC },
}),
);
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ city: true }],
limit: 2,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
expect(groups).toHaveLength(2);
});
it('computes aggregated metrics on date time field', async () => {
const cityA = 'City A';
const cityB = 'City B';
const person1 = (
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPersonId, city: cityA },
}),
)
).body.data.createPerson;
const person2 = (
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: testPerson2Id, city: cityB },
}),
)
).body.data.createPerson;
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 }],
orderBy: [{ city: OrderByDirection.AscNullsFirst }], // needed for City groups to be in 300 first groups
gqlFields: 'minCreatedAt',
limit: 300,
}),
);
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.minCreatedAt).toEqual(person1.createdAt);
const groupWithCityB = groups.find(
(group: any) => group.groupByDimensionValues[0] === cityB,
);
expect(groupWithCityB.groupByDimensionValues).toEqual([cityB]);
expect(groupWithCityB.minCreatedAt).toEqual(person2.createdAt);
});
});
describe('date range', () => {
const testPersonId = randomUUID();
const testPerson2Id = randomUUID();
const testPerson3Id = randomUUID();
const idJan2 = testPersonId;
const idJan8 = testPerson2Id;
const idMar3 = testPerson3Id;
beforeAll(async () => {
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: {
lt: '2025-03-04T00:00:00.000Z',
},
},
],
};
it('datetime field - groups by createdAt MONTH', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{ createdAt: { granularity: 'MONTH', timeZone: 'Europe/Paris' } },
],
filter: filter2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
expect(groups.length).toBe(2);
const groupWith2Records = groups.find((g: any) => g.totalCount === 2);
const groupWith1Record = groups.find((g: any) => g.totalCount === 1);
expect(groupWith2Records).toBeDefined();
expect(groupWith1Record).toBeDefined();
expect(groupWith2Records.totalCount).toBe(2);
expect(groupWith1Record.totalCount).toBe(1);
});
describe('group by week', () => {
const idMarch1st = randomUUID();
const idMarch2nd = randomUUID();
const idMarch3rd = randomUUID();
beforeAll(async () => {
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: idMarch3rd, createdAt: '2025-03-03T09:30:00.000Z' }, // monday, march, Q1, 2025
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: idMarch2nd, createdAt: '2025-03-02T09:30:00.000Z' }, // sunday, march, Q1, 2025
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: { id: idMarch1st, createdAt: '2025-03-01T09:30:00.000Z' }, // saturday, march, Q1, 2025
}),
);
});
afterAll(async () => {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: idMarch1st,
}),
);
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: idMarch2nd,
}),
);
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: idMarch3rd,
}),
);
});
it('datetime field - groups by createdAt WEEK with default (MONDAY)', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{ createdAt: { granularity: 'WEEK', timeZone: 'Europe/Paris' } },
],
gqlFields: `
edges {
node {
id
}
}
`,
filter: filter2025,
limit: 10,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(groups.length).toBe(4);
// Group starting week of monday dec 30th, 2024
const mondayDec30thGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2024-12-30'),
);
expect(mondayDec30thGroup).toBeDefined();
expect(mondayDec30thGroup.edges[0].node.id).toBe(idJan2);
expect(mondayDec30thGroup.totalCount).toBe(1);
// Group starting week of monday jan 6th, 2025
const mondayJan6thGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2025-01-06'),
);
expect(mondayJan6thGroup).toBeDefined();
expect(mondayJan6thGroup.edges[0].node.id).toBe(idJan8);
expect(mondayJan6thGroup.totalCount).toBe(1);
// Group starting week of monday feb 24th, 2025
const mondayFeb24thGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2025-02-24'),
);
expect(mondayFeb24thGroup).toBeDefined();
expect(mondayFeb24thGroup.edges.length).toBe(2);
expect(
mondayFeb24thGroup.edges.find(
(edge: any) => edge.node.id === idMarch2nd,
),
).toBeDefined;
expect(
mondayFeb24thGroup.edges.find(
(edge: any) => edge.node.id === idMarch1st,
),
).toBeDefined;
// Group starting week of monday march 3rd, 2025
const mondayMarch3rdGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2025-03-03'),
);
expect(mondayMarch3rdGroup).toBeDefined();
expect(mondayMarch3rdGroup.edges.length).toBe(2);
expect(
mondayMarch3rdGroup.edges.find(
(edge: any) => edge.node.id === idMarch3rd,
),
).toBeDefined;
expect(
mondayMarch3rdGroup.edges.find(
(edge: any) => edge.node.id === idMar3,
),
).toBeDefined;
});
it('datetime field - groups by createdAt WEEK with weekStartDay SUNDAY', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
createdAt: {
granularity: 'WEEK',
weekStartDay: 'SUNDAY',
timeZone: 'Europe/Paris',
},
},
],
gqlFields: `
edges {
node {
id
}
}
`,
filter: filter2025,
limit: 10,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(groups.length).toBe(4);
// Group starting week of sunday dec 29th, 2024
const sundayDec29thGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2024-12-29'),
);
expect(sundayDec29thGroup).toBeDefined();
expect(sundayDec29thGroup.totalCount).toBe(1);
expect(
sundayDec29thGroup.edges.find((edge: any) => edge.node.id === idJan2),
).toBeDefined();
// Group starting week of sunday jan 5th, 2025
const sundayJan5thGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2025-01-05'),
);
expect(sundayJan5thGroup).toBeDefined();
expect(sundayJan5thGroup.totalCount).toBe(1);
expect(
sundayJan5thGroup.edges.find((edge: any) => edge.node.id === idJan8),
).toBeDefined();
// Group starting week of sunday feb 23rd, 2025
const sundayFeb23rdGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2025-02-23'),
);
expect(sundayFeb23rdGroup).toBeDefined();
expect(sundayFeb23rdGroup.totalCount).toBe(1);
expect(
sundayFeb23rdGroup.edges.find(
(edge: any) => edge.node.id === idMarch1st,
),
).toBeDefined();
// Group starting week of sunday march 2nd, 2025
const sundayMarch2ndGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2025-03-02'),
);
expect(sundayMarch2ndGroup).toBeDefined();
expect(sundayMarch2ndGroup.totalCount).toBe(3);
expect(
sundayMarch2ndGroup.edges.find(
(edge: any) => edge.node.id === idMarch2nd,
),
).toBeDefined();
expect(
sundayMarch2ndGroup.edges.find(
(edge: any) => edge.node.id === idMarch3rd,
),
).toBeDefined();
expect(
sundayMarch2ndGroup.edges.find(
(edge: any) => edge.node.id === idMar3,
),
).toBeDefined();
});
it('datetime field - groups by createdAt WEEK with weekStartDay SATURDAY', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
createdAt: {
granularity: 'WEEK',
weekStartDay: 'SATURDAY',
timeZone: 'Europe/Paris',
},
},
],
gqlFields: `
edges {
node {
id
}
}
`,
filter: filter2025,
limit: 10,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(groups.length).toBe(3);
// Group starting week of saturday dec 28th, 2024
const saturdayDec28thGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2024-12-28'),
);
expect(saturdayDec28thGroup).toBeDefined();
expect(saturdayDec28thGroup.totalCount).toBe(1);
expect(
saturdayDec28thGroup.edges.find(
(edge: any) => edge.node.id === idJan2,
),
).toBeDefined();
// Group starting week of saturday jan 4th, 2025
const saturdayJan4thGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2025-01-04'),
);
expect(saturdayJan4thGroup).toBeDefined();
expect(saturdayJan4thGroup.totalCount).toBe(1);
expect(
saturdayJan4thGroup.edges.find(
(edge: any) => edge.node.id === idJan8,
),
).toBeDefined();
// Group starting week of saturday march 1st, 2025
const saturdayMarch1stGroup = groups.find((group: any) =>
group.groupByDimensionValues[0].startsWith('2025-03-01'),
);
expect(saturdayMarch1stGroup).toBeDefined();
expect(saturdayMarch1stGroup.totalCount).toBe(4);
expect(
saturdayMarch1stGroup.edges.find(
(edge: any) => edge.node.id === idMarch1st,
),
).toBeDefined();
expect(
saturdayMarch1stGroup.edges.find(
(edge: any) => edge.node.id === idMarch2nd,
),
).toBeDefined();
expect(
saturdayMarch1stGroup.edges.find(
(edge: any) => edge.node.id === idMarch3rd,
),
).toBeDefined();
expect(
saturdayMarch1stGroup.edges.find(
(edge: any) => edge.node.id === idMar3,
),
).toBeDefined();
});
});
describe('cyclic date', () => {
const filter2024And2025 = {
and: [
{
createdAt: {
gte: '2024-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lt: '2025-03-04T00:00:00.000Z',
},
},
],
};
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 { data: createViewData } = await createOneCoreView({
input: {
name: 'People View City Keep',
objectMetadataId: personObjectMetadataId,
icon: 'Icon123',
},
expectToFail: false,
});
viewId = createViewData.createCoreView.id;
// 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 { data: createViewData } = await createOneCoreView({
input: {
name: 'People View City Keep',
objectMetadataId: personObjectMetadataId,
icon: 'Icon123',
anyFieldFilterValue: cityA,
},
expectToFail: false,
});
viewId = createViewData.createCoreView.id;
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ city: true }],
viewId,
orderBy: [{ city: OrderByDirection.AscNullsFirst }], // needed for City groups to be in 300 first groups
limit: 300,
}),
);
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('relation fields', () => {
describe('group by relation fields', () => {
const testPersonId = randomUUID();
const testPerson2Id = randomUUID();
const testPerson3Id = randomUUID();
const testCompanyId = randomUUID();
const testCompany2Id = randomUUID();
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
beforeAll(async () => {
// Create companies with different createdAt dates for grouping
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId,
name: 'Company A',
createdAt: '2025-01-02T12:00:00.000Z', // Thursday
address: {
addressCity: 'City A',
},
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompany2Id,
name: 'Company B',
createdAt: '2025-01-08T08:00:00.000Z', // Wednesday
address: {
addressCity: 'City B',
},
},
}),
);
// Create people linked to companies
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPersonId,
companyId: testCompanyId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPerson2Id,
companyId: testCompanyId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPerson3Id,
companyId: testCompany2Id,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup people
for (const id of [testPersonId, testPerson2Id, testPerson3Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup companies
for (const id of [testCompanyId, testCompany2Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('groups by one relation field - company createdAt with DAY_OF_THE_WEEK granularity', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
company: {
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
},
},
},
],
filter: filter2025,
}),
);
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 wednesdayGroup = groups.find((g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday'),
);
expect(thursdayGroup).toBeDefined();
expect(wednesdayGroup).toBeDefined();
expect(thursdayGroup.totalCount).toBe(2);
expect(wednesdayGroup.totalCount).toBe(1);
});
it('groups by one relation field - company createdAt with WEEK granularity and weekStartDay SUNDAY', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
company: {
createdAt: {
granularity: 'WEEK',
weekStartDay: 'SUNDAY',
},
},
},
],
filter: filter2025,
limit: 10,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
// Verify that all records are grouped
const totalCount = groups.reduce(
(sum: number, group: any) => sum + group.totalCount,
0,
);
expect(totalCount).toBe(3);
});
it('groups by two relation fields from the same joined table', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
company: {
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
},
},
},
{
company: {
address: {
addressCity: true,
},
},
},
],
filter: filter2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const thursdayCityAGroup = groups.find(
(g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Thursday') &&
g.groupByDimensionValues?.[1] === 'City A',
);
const wednesdayCityBGroup = groups.find(
(g: any) =>
g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday') &&
g.groupByDimensionValues?.[1] === 'City B',
);
expect(thursdayCityAGroup).toBeDefined();
expect(wednesdayCityBGroup).toBeDefined();
expect(thursdayCityAGroup.totalCount).toBe(2);
expect(wednesdayCityBGroup.totalCount).toBe(1);
});
});
describe('group by relation fields from different tables', () => {
const testPersonId = randomUUID();
const testPerson2Id = randomUUID();
const testPerson3Id = randomUUID();
const testCompanyId = randomUUID();
const testCompany2Id = randomUUID();
const testlistingId = randomUUID();
const testlisting2Id = randomUUID();
let listingObjectMetadataId: string;
let personlistingRelationFieldId: string;
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
beforeAll(async () => {
// Find person and company object metadata
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: {
first: 100,
},
},
gqlFields: 'id nameSingular',
expectToFail: false,
});
const personObject = objects.find((o) => o.nameSingular === 'person');
const companyObject = objects.find((o) => o.nameSingular === 'company');
if (!personObject?.id || !companyObject?.id) {
throw new Error('Person or Company object not found');
}
// Create listing object
const { data: createListingObjectData } = await createOneObjectMetadata(
{
input: {
nameSingular: 'listing',
namePlural: 'listings',
labelSingular: 'Listing',
labelPlural: 'Listings',
icon: 'IconPaw',
},
expectToFail: false,
},
);
listingObjectMetadataId = createListingObjectData.createOneObject.id;
// Create relation from person to listing
const personlistingRelation = await createRelationBetweenObjects({
objectMetadataId: personObject.id,
targetObjectMetadataId: listingObjectMetadataId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'assignedListing',
});
personlistingRelationFieldId = personlistingRelation.id;
// Create companies
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId,
name: 'Company A',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompany2Id,
name: 'Company B',
},
}),
);
// Create listings
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'listing',
gqlFields: 'id name',
data: {
id: testlistingId,
name: 'Flat in Paris',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'listing',
gqlFields: 'id name',
data: {
id: testlisting2Id,
name: 'House in Marseille',
},
}),
);
// Create people linked to companies and listings
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPersonId,
companyId: testCompanyId,
assignedListingId: testlistingId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPerson2Id,
companyId: testCompanyId,
assignedListingId: testlisting2Id,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: testPerson3Id,
companyId: testCompanyId,
assignedListingId: testlisting2Id,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup people
for (const id of [testPersonId, testPerson2Id, testPerson3Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup listings
for (const id of [testlistingId, testlisting2Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'listing',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup company
for (const id of [testCompanyId, testCompany2Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup relation field
if (isDefined(personlistingRelationFieldId)) {
await updateOneFieldMetadata({
input: {
idToUpdate: personlistingRelationFieldId,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: personlistingRelationFieldId },
expectToFail: false,
});
}
// Cleanup listing object
if (isDefined(listingObjectMetadataId)) {
await updateOneObjectMetadata({
input: {
idToUpdate: listingObjectMetadataId,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
});
await deleteOneObjectMetadata({
input: { idToDelete: listingObjectMetadataId },
expectToFail: false,
});
}
});
it('groups by two relation fields from different tables', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [
{
company: {
name: true,
},
},
{
assignedListing: {
name: true,
},
},
],
filter: filter2025,
}),
);
const groups = response.body.data.peopleGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const companyAFlatInParisGroup = groups.find(
(g: any) =>
g.groupByDimensionValues?.[0] === 'Company A' &&
g.groupByDimensionValues?.[1] === 'Flat in Paris',
);
const companyAHouseInMarseilleGroup = groups.find(
(g: any) =>
g.groupByDimensionValues?.[0] === 'Company A' &&
g.groupByDimensionValues?.[1] === 'House in Marseille',
);
expect(companyAFlatInParisGroup).toBeDefined();
expect(companyAHouseInMarseilleGroup).toBeDefined();
expect(companyAFlatInParisGroup.totalCount).toBe(1);
expect(companyAHouseInMarseilleGroup.totalCount).toBe(2);
});
});
describe('group by morph relation fields', () => {
const testPetId = randomUUID();
const testPet2Id = randomUUID();
const testPet3Id = randomUUID();
const testRocketId = randomUUID();
const testRocket2Id = randomUUID();
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
beforeAll(async () => {
// Create rockets with different names
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id name',
data: {
id: testRocketId,
name: 'Rocket A',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id name',
data: {
id: testRocket2Id,
name: 'Rocket B',
},
}),
);
// Create pets linked to rockets via morph relation
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id name ownerRocket { id name }',
data: {
id: testPetId,
name: 'Pet 1',
ownerRocketId: testRocketId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id name ownerRocket { id name }',
data: {
id: testPet2Id,
name: 'Pet 2',
ownerRocketId: testRocketId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id name ownerRocket { id name }',
data: {
id: testPet3Id,
name: 'Pet 3',
ownerRocketId: testRocket2Id,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup pets
for (const id of [testPetId, testPet2Id, testPet3Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup rockets
for (const id of [testRocketId, testRocket2Id]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('groups by morph relation field - ownerRocket name', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'pet',
objectMetadataPluralName: 'pets',
groupBy: [
{
ownerRocket: {
name: true,
},
},
],
filter: filter2025,
}),
);
const groups = response.body.data.petsGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const rocketAGroup = groups.find(
(g: any) => g.groupByDimensionValues?.[0] === 'Rocket A',
);
const rocketBGroup = groups.find(
(g: any) => g.groupByDimensionValues?.[0] === 'Rocket B',
);
expect(rocketAGroup).toBeDefined();
expect(rocketBGroup).toBeDefined();
expect(rocketAGroup.totalCount).toBe(2);
expect(rocketBGroup.totalCount).toBe(1);
});
});
describe('should throw if group by a relation field user does not have reading rights on', () => {
const testPetId = randomUUID();
const testRocketId = randomUUID();
let customRoleId: string;
let petObjectId: string;
let rocketObjectId: string;
let originalMemberRoleId: string;
beforeAll(async () => {
// Get the original Member role ID for restoration later
const getRolesQuery = {
query: `
query GetRoles {
getRoles {
id
label
}
}
`,
};
const rolesResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(getRolesQuery);
originalMemberRoleId = rolesResponse.body.data.getRoles.find(
(role: any) => role.label === 'Member',
).id;
// Get object metadata IDs for pet and rocket
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: {
first: 100,
},
},
gqlFields: 'id nameSingular',
expectToFail: false,
});
const petObject = objects.find((o) => o.nameSingular === 'pet');
const rocketObject = objects.find((o) => o.nameSingular === 'rocket');
if (!petObject?.id || !rocketObject?.id) {
throw new Error('Pet or Rocket object not found');
}
petObjectId = petObject.id;
rocketObjectId = rocketObject.id;
// Create a custom role with pet read permission but no rocket read permission
const createRoleOperation = {
query: gql`
mutation CreateOneRole {
createOneRole(
createRoleInput: {
label: "PetOnlyRole"
description: "Test role with pet read permission but no rocket read permission"
canUpdateAllSettings: false
canReadAllObjectRecords: false
canUpdateAllObjectRecords: false
canSoftDeleteAllObjectRecords: false
canDestroyAllObjectRecords: false
}
) {
id
label
}
}
`,
};
const createRoleResponse =
await makeGraphqlAPIRequest(createRoleOperation);
customRoleId = createRoleResponse.body.data.createOneRole.id;
// Set object permissions: allow reading pets but not rockets
const upsertObjectPermissionsOperation = {
query: gql`
mutation UpsertObjectPermissions(
$roleId: UUID!
$objectPermissions: [ObjectPermissionInput!]!
) {
upsertObjectPermissions(
upsertObjectPermissionsInput: {
roleId: $roleId
objectPermissions: $objectPermissions
}
) {
objectMetadataId
canReadObjectRecords
}
}
`,
variables: {
roleId: customRoleId,
objectPermissions: [
{
objectMetadataId: petObjectId,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectMetadataId: rocketObjectId,
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
},
};
await makeGraphqlAPIRequest(upsertObjectPermissionsOperation);
// Assign the custom role to a workspace member
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
});
// Create a rocket
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id name',
data: {
id: testRocketId,
name: 'Test Rocket',
},
}),
);
// Create a pet linked to the rocket
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id name ownerRocket { id name }',
data: {
id: testPetId,
name: 'Test Pet',
ownerRocketId: testRocketId,
createdAt: '2025-03-03T09:30:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup pet
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'pet',
gqlFields: 'id',
recordId: testPetId,
}),
);
// Cleanup rocket
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'rocket',
gqlFields: 'id',
recordId: testRocketId,
}),
);
// // Restore original role
const restoreMemberRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(
workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JONY}"
roleId: "${originalMemberRoleId}"
) {
id
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(restoreMemberRoleQuery);
// // Delete custom role
if (isDefined(customRoleId)) {
await deleteRole(client, customRoleId);
}
});
it('should throw a permission error when grouping by ownerRocket field without read permission', async () => {
const filter2025 = {
and: [
{
createdAt: {
gte: '2025-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2025-03-03T23:59:59.999Z',
},
},
],
};
const response = await makeGraphqlAPIRequestWithMemberRole(
groupByOperationFactory({
objectMetadataSingularName: 'pet',
objectMetadataPluralName: 'pets',
groupBy: [
{
ownerRocket: {
name: true,
},
},
],
filter: filter2025,
}),
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});