Validate timezone input in group-by date queries (#17777)
## Summary - Validates that the timezone parameter in group-by date expressions is a recognized IANA timezone - Adds SQL string literal escaping as a defense-in-depth measure for the timezone value interpolated into SQL expressions - Moves the `IANA_TIME_ZONES` constant to `twenty-shared` so it can be reused across frontend and server packages - Adds `INVALID_TIMEZONE` error code mapped to 400 Bad Request in both GraphQL and REST API exception handlers ## Test plan - [x] Unit tests for `validateIanaTimeZone` (valid IANA zones, fixed offsets, rejects invalid strings) - [x] Unit tests for `escapeSqlStringLiteral` - [x] Integration tests for `getGroupByExpression` covering timezone validation and granularity handling Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
+2
-2
@@ -20,8 +20,8 @@ import {
|
||||
type GroupByDateField,
|
||||
type GroupByField,
|
||||
type GroupByRegularField,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { getGroupByOrderExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-order-expression.util';
|
||||
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { getGroupByOrderExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-order-expression.util';
|
||||
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
|
||||
import {
|
||||
type AggregationField,
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
type GroupByField,
|
||||
type GroupByRelationField,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
|
||||
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import {
|
||||
GraphqlQuerySelectedFieldsParser,
|
||||
type GraphqlQuerySelectedFieldsResult,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
|
||||
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
export type GroupByDefinition = {
|
||||
columnNameWithQuotes: string;
|
||||
expression: string;
|
||||
alias: string;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
};
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { type FirstDayOfTheWeek } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export type GroupByRegularField = {
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
subFieldName?: string;
|
||||
};
|
||||
|
||||
export type GroupByDateField = {
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
subFieldName?: string;
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity;
|
||||
weekStartDay?: FirstDayOfTheWeek;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export type GroupByRelationField = {
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
nestedFieldMetadata: FlatFieldMetadata;
|
||||
nestedSubFieldName?: string;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
weekStartDay?: FirstDayOfTheWeek;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export type GroupByField =
|
||||
| GroupByRegularField
|
||||
| GroupByDateField
|
||||
| GroupByRelationField;
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
|
||||
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
|
||||
import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from 'twenty-shared/constants';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
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';
|
||||
|
||||
export const getGroupByExpression = ({
|
||||
groupByField,
|
||||
columnNameWithQuotes,
|
||||
}: {
|
||||
groupByField: GroupByField;
|
||||
columnNameWithQuotes: string;
|
||||
}) => {
|
||||
if (
|
||||
!(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
|
||||
) {
|
||||
return columnNameWithQuotes;
|
||||
}
|
||||
|
||||
const dateGranularity = groupByField.dateGranularity;
|
||||
|
||||
if (!isDefined(dateGranularity)) {
|
||||
return columnNameWithQuotes;
|
||||
}
|
||||
|
||||
const shouldUseTimeZone =
|
||||
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes(
|
||||
dateGranularity,
|
||||
) && groupByField.fieldMetadata.type === FieldMetadataType.DATE_TIME;
|
||||
|
||||
const timeZoneIsNotProvided = !isNonEmptyString(groupByField.timeZone);
|
||||
|
||||
if (shouldUseTimeZone && timeZoneIsNotProvided) {
|
||||
throw new CommonQueryRunnerException(
|
||||
'Time zone should be specified for a group by date on Day, Week, Month, Quarter or Year',
|
||||
CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const timeZoneAsDateTruncParameter = shouldUseTimeZone
|
||||
? `, '${groupByField.timeZone}'`
|
||||
: '';
|
||||
|
||||
const timeZoneAsToCharParameter = shouldUseTimeZone
|
||||
? ` AT TIME ZONE '${groupByField.timeZone}'`
|
||||
: '';
|
||||
|
||||
switch (dateGranularity) {
|
||||
case ObjectRecordGroupByDateGranularity.NONE:
|
||||
return columnNameWithQuotes;
|
||||
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
|
||||
return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMDay'))`;
|
||||
case ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR:
|
||||
return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMMonth'))`;
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR:
|
||||
return `TRIM(TO_CHAR(${columnNameWithQuotes}, '"Q"Q'))`;
|
||||
case ObjectRecordGroupByDateGranularity.WEEK: {
|
||||
const weekStartDay = groupByField.weekStartDay;
|
||||
let shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes}${timeZoneAsDateTruncParameter})`;
|
||||
|
||||
if (isDefined(weekStartDay)) {
|
||||
if (weekStartDay === 'SUNDAY') {
|
||||
shiftedExpression = `(DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '1 day'${timeZoneAsDateTruncParameter}) - INTERVAL '1 day')`;
|
||||
} else if (weekStartDay === 'SATURDAY') {
|
||||
shiftedExpression = `(DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '2 days'${timeZoneAsDateTruncParameter}) - INTERVAL '2 days')`;
|
||||
}
|
||||
}
|
||||
|
||||
return `TO_CHAR(${shiftedExpression}${timeZoneAsToCharParameter}, 'YYYY-MM-DD')`;
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.DAY:
|
||||
case ObjectRecordGroupByDateGranularity.MONTH:
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER:
|
||||
case ObjectRecordGroupByDateGranularity.YEAR:
|
||||
return `TO_CHAR(DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes}${timeZoneAsDateTruncParameter})${timeZoneAsToCharParameter}, 'YYYY-MM-DD')`;
|
||||
default:
|
||||
assertUnreachable(dateGranularity);
|
||||
}
|
||||
};
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
import {
|
||||
type GroupByDateField,
|
||||
type GroupByField,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
|
||||
export const isGroupByDateField = (
|
||||
groupByField: GroupByField,
|
||||
): groupByField is GroupByDateField => {
|
||||
return (
|
||||
'dateGranularity' in groupByField &&
|
||||
!('nestedFieldMetadata' in groupByField)
|
||||
);
|
||||
};
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import {
|
||||
type GroupByField,
|
||||
type GroupByRelationField,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
|
||||
export const isGroupByRelationField = (
|
||||
groupByField: GroupByField,
|
||||
): groupByField is GroupByRelationField => {
|
||||
return 'nestedFieldMetadata' in groupByField;
|
||||
};
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
|
||||
import { parseGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util';
|
||||
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type';
|
||||
import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type';
|
||||
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
|
||||
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
const removeQuotes = (string: string): string => {
|
||||
return string.replace(/["']/g, '');
|
||||
};
|
||||
|
||||
export const formatColumnNameAsAlias = (
|
||||
columnNameWithQuotes: string,
|
||||
): string => {
|
||||
return removeQuotes(columnNameWithQuotes).replace(/\./g, '_');
|
||||
};
|
||||
+1
-1
@@ -14,7 +14,7 @@ import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/co
|
||||
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
|
||||
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
|
||||
import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
|
||||
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
|
||||
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
|
||||
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
|
||||
|
||||
Reference in New Issue
Block a user