[GroupBy] Fix order by date granularity (#17573)

https://discord.com/channels/1130383047699738754/1466472357496623165/1466472357496623165

before
<img width="1262" height="598" alt="image"
src="https://github.com/user-attachments/assets/e6fb9a13-58c0-408d-b3e9-a486ec04c33c"
/>

after
<img width="670" height="267" alt="image"
src="https://github.com/user-attachments/assets/b4b5ab19-1144-4300-9801-b8220ce928f9"
/>
This commit is contained in:
Marie
2026-01-30 17:40:36 +01:00
committed by GitHub
parent ebc7125b20
commit 15b053de46
5 changed files with 242 additions and 23 deletions
@@ -21,7 +21,7 @@ import {
type GroupByField,
type GroupByRegularField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
import { getGroupByOrderExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-order-expression.util';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import {
type AggregationField,
@@ -493,7 +493,7 @@ export class GraphqlQueryOrderGroupByParser {
)[0]
}"`;
const expression = getGroupByExpression({
const expression = getGroupByOrderExpression({
groupByField: associatedGroupByField,
columnNameWithQuotes,
});
@@ -620,7 +620,7 @@ export class GraphqlQueryOrderGroupByParser {
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
const expression = getGroupByExpression({
const expression = getGroupByOrderExpression({
groupByField: associatedGroupByField,
columnNameWithQuotes,
});
@@ -0,0 +1,82 @@
import {
FirstDayOfTheWeek,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
const DAYS_OF_WEEK = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
] as const;
const getDayOfWeekOrderExpression = (
groupByExpression: string,
weekStartDay?: FirstDayOfTheWeek,
): string => {
const startDay = weekStartDay ?? FirstDayOfTheWeek.MONDAY;
const startIndex =
startDay === FirstDayOfTheWeek.SUNDAY
? 6
: startDay === FirstDayOfTheWeek.SATURDAY
? 5
: 0;
const orderedDays = [
...DAYS_OF_WEEK.slice(startIndex),
...DAYS_OF_WEEK.slice(0, startIndex),
];
const caseConditions = orderedDays
.map((day, index) => `WHEN '${day}' THEN ${index + 1}`)
.join(' ');
return `CASE ${groupByExpression} ${caseConditions} END`;
};
export const getGroupByOrderExpression = ({
groupByField,
columnNameWithQuotes,
}: {
groupByField: GroupByField;
columnNameWithQuotes: string;
}): string => {
if (
!(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
) {
return getGroupByExpression({ groupByField, columnNameWithQuotes });
}
const dateGranularity = groupByField.dateGranularity;
if (!isDefined(dateGranularity)) {
return getGroupByExpression({ groupByField, columnNameWithQuotes });
}
const groupByExpression = getGroupByExpression({
groupByField,
columnNameWithQuotes,
});
switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
return getDayOfWeekOrderExpression(
groupByExpression,
groupByField.weekStartDay,
);
case ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR:
return `CASE ${groupByExpression} WHEN 'January' THEN 1 WHEN 'February' THEN 2 WHEN 'March' THEN 3 WHEN 'April' THEN 4 WHEN 'May' THEN 5 WHEN 'June' THEN 6 WHEN 'July' THEN 7 WHEN 'August' THEN 8 WHEN 'September' THEN 9 WHEN 'October' THEN 10 WHEN 'November' THEN 11 WHEN 'December' THEN 12 END`;
default:
return groupByExpression;
}
};
@@ -51,8 +51,12 @@ const buildDateGroupByObject = ({
result.timeZone = timeZone;
}
const shouldIncludeWeekStartDay =
usedDateGranularity === ObjectRecordGroupByDateGranularity.WEEK ||
usedDateGranularity === ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK;
if (
usedDateGranularity === ObjectRecordGroupByDateGranularity.WEEK &&
shouldIncludeWeekStartDay &&
isDefined(firstDayOfTheWeek) &&
firstDayOfTheWeek !== CalendarStartDay.SYSTEM
) {
@@ -49,10 +49,11 @@ export const compareDimensionValues = ({
direction === 'ASC' ? comparison : -comparison;
if (isDefined(fieldType)) {
if (
isFieldMetadataDateKind(fieldType) &&
!isCyclicalDateGranularity(dateGranularity)
) {
if (isFieldMetadataDateKind(fieldType)) {
if (isCyclicalDateGranularity(dateGranularity)) {
return 0;
}
const dateA = parseDate(rawValueA);
const dateB = parseDate(rawValueB);
@@ -204,7 +204,7 @@ describe('group-by resolvers - order by', () => {
totalCount: g.totalCount,
}));
// Order by dayOfWeek then avgEmployees then city
// Order by dayOfWeek (chronological) then avgEmployees then city
expect(groupInfos).toEqual([
{
city: 'Dallas',
@@ -234,13 +234,6 @@ describe('group-by resolvers - order by', () => {
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Paris',
dayOfWeek: 'Thursday',
avgEmployees: 10,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Barcelona',
dayOfWeek: 'Wednesday',
@@ -248,6 +241,13 @@ describe('group-by resolvers - order by', () => {
totalCount: 2,
annualRecurringRevenue: '100',
},
{
city: 'Paris',
dayOfWeek: 'Thursday',
avgEmployees: 10,
totalCount: 1,
annualRecurringRevenue: '100',
},
]);
});
it('should order results in the right order - createdAt, addressCity, avgEmployees', async () => {
@@ -284,6 +284,7 @@ describe('group-by resolvers - order by', () => {
totalCount: g.totalCount,
}));
// Order by dayOfWeek (chronological) then addressCity then avgEmployees
expect(groupInfos).toEqual([
{
city: 'Anvers',
@@ -313,13 +314,6 @@ describe('group-by resolvers - order by', () => {
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Paris',
dayOfWeek: 'Thursday',
avgEmployees: 10,
totalCount: 1,
annualRecurringRevenue: '100',
},
{
city: 'Barcelona',
dayOfWeek: 'Wednesday',
@@ -327,6 +321,13 @@ describe('group-by resolvers - order by', () => {
totalCount: 2,
annualRecurringRevenue: '100',
},
{
city: 'Paris',
dayOfWeek: 'Thursday',
avgEmployees: 10,
totalCount: 1,
annualRecurringRevenue: '100',
},
]);
});
it('should order results in the right order - addressCity, createdAt, avgEmployees', async () => {
@@ -489,6 +490,137 @@ describe('group-by resolvers - order by', () => {
});
});
describe('chronological ordering for date granularities', () => {
it('should order DAY_OF_THE_WEEK chronologically (Monday=1 to Sunday=7), not alphabetically', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
groupBy: [{ createdAt: { granularity: 'DAY_OF_THE_WEEK' } }],
orderBy: [
{
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
orderBy: 'AscNullsFirst',
},
},
],
filter: filter2025,
gqlFields: `
totalCount
`,
}),
);
const groups = response.body.data.companiesGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const dayOrder = groups.map((g: any) => g.groupByDimensionValues?.[0]);
// Monday (1), Wednesday (3), Thursday (4) - chronological order
expect(dayOrder).toEqual(['Monday', 'Wednesday', 'Thursday']);
});
it('should order DAY_OF_THE_WEEK in descending chronological order', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
groupBy: [{ createdAt: { granularity: 'DAY_OF_THE_WEEK' } }],
orderBy: [
{
createdAt: {
granularity: 'DAY_OF_THE_WEEK',
orderBy: 'DescNullsLast',
},
},
],
filter: filter2025,
gqlFields: `
totalCount
`,
}),
);
const groups = response.body.data.companiesGroupBy;
expect(groups).toBeDefined();
const dayOrder = groups.map((g: any) => g.groupByDimensionValues?.[0]);
// Thursday (4), Wednesday (3), Monday (1) - reverse chronological order
expect(dayOrder).toEqual(['Thursday', 'Wednesday', 'Monday']);
});
it('should order MONTH_OF_THE_YEAR chronologically (January=1 to December=12), not alphabetically', async () => {
// Test data has January (companies 4,5,6) and March (companies 1,2,3,7)
// Chronological order: January (1), March (3)
// Alphabetical would be: January, March (same in this case, but tests the mechanism)
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
groupBy: [{ createdAt: { granularity: 'MONTH_OF_THE_YEAR' } }],
orderBy: [
{
createdAt: {
granularity: 'MONTH_OF_THE_YEAR',
orderBy: 'AscNullsFirst',
},
},
],
filter: filter2025,
gqlFields: `
totalCount
`,
}),
);
const groups = response.body.data.companiesGroupBy;
expect(groups).toBeDefined();
expect(Array.isArray(groups)).toBe(true);
const monthOrder = groups.map((g: any) => g.groupByDimensionValues?.[0]);
// January (1), March (3) - chronological order
expect(monthOrder).toEqual(['January', 'March']);
});
it('should order MONTH_OF_THE_YEAR in descending chronological order', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
groupBy: [{ createdAt: { granularity: 'MONTH_OF_THE_YEAR' } }],
orderBy: [
{
createdAt: {
granularity: 'MONTH_OF_THE_YEAR',
orderBy: 'DescNullsLast',
},
},
],
filter: filter2025,
gqlFields: `
totalCount
`,
}),
);
const groups = response.body.data.companiesGroupBy;
expect(groups).toBeDefined();
const monthOrder = groups.map((g: any) => g.groupByDimensionValues?.[0]);
// March (3), January (1) - reverse chronological order
expect(monthOrder).toEqual(['March', 'January']);
});
});
describe('invalid cases', () => {
it('should fail if attempt to order by a field that is not part of the groupBy', async () => {
const response = await makeGraphqlAPIRequest(