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:
+3
-3
@@ -36,10 +36,10 @@ import {
|
||||
} from 'src/engine/api/common/types/common-query-args.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 { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
|
||||
import { GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
|
||||
import { GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
|
||||
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
|
||||
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
|
||||
import { parseGroupByArgs } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util';
|
||||
import { GroupByWithRecordsService } from 'src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service';
|
||||
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ export enum CommonQueryRunnerExceptionCode {
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
TOO_COMPLEX_QUERY = 'TOO_COMPLEX_QUERY',
|
||||
MISSING_TIMEZONE_FOR_DATE_GROUP_BY = 'MISSING_TIMEZONE_FOR_DATE_GROUP_BY',
|
||||
INVALID_TIMEZONE = 'INVALID_TIMEZONE',
|
||||
}
|
||||
|
||||
export class CommonQueryRunnerException extends CustomException<CommonQueryRunnerExceptionCode> {
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
export type GroupByDefinition = {
|
||||
columnNameWithQuotes: string;
|
||||
expression: string;
|
||||
alias: string;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { type GroupByDateField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { getGroupByExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-expression.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
const buildDateTimeFieldMetadata = (): FlatFieldMetadata => {
|
||||
return {
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
name: 'createdAt',
|
||||
} as FlatFieldMetadata;
|
||||
};
|
||||
|
||||
const buildGroupByDateField = (
|
||||
overrides: Partial<GroupByDateField> = {},
|
||||
): GroupByDateField => ({
|
||||
fieldMetadata: buildDateTimeFieldMetadata(),
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: 'America/New_York',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getGroupByExpression', () => {
|
||||
const columnNameWithQuotes = '"company"."createdAt"';
|
||||
|
||||
describe('timezone validation', () => {
|
||||
it('should accept valid IANA timezones', () => {
|
||||
const groupByField = buildGroupByDateField({
|
||||
timeZone: 'America/New_York',
|
||||
});
|
||||
|
||||
const result = getGroupByExpression({
|
||||
groupByField,
|
||||
columnNameWithQuotes,
|
||||
});
|
||||
|
||||
expect(result).toContain("'America/New_York'");
|
||||
});
|
||||
|
||||
it('should accept UTC timezone', () => {
|
||||
const groupByField = buildGroupByDateField({ timeZone: 'UTC' });
|
||||
|
||||
const result = getGroupByExpression({
|
||||
groupByField,
|
||||
columnNameWithQuotes,
|
||||
});
|
||||
|
||||
expect(result).toContain("'UTC'");
|
||||
});
|
||||
|
||||
it('should reject SQL injection in timezone field', () => {
|
||||
const groupByField = buildGroupByDateField({
|
||||
timeZone: "UTC'; DROP TABLE users; --",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
getGroupByExpression({ groupByField, columnNameWithQuotes }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should reject UNION-based injection in timezone', () => {
|
||||
const groupByField = buildGroupByDateField({
|
||||
timeZone: "UTC') UNION SELECT * FROM pg_shadow --",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
getGroupByExpression({ groupByField, columnNameWithQuotes }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should reject arbitrary strings as timezone', () => {
|
||||
const groupByField = buildGroupByDateField({
|
||||
timeZone: 'not_a_real_timezone',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
getGroupByExpression({ groupByField, columnNameWithQuotes }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing timezone handling', () => {
|
||||
it('should throw when timezone is required but not provided', () => {
|
||||
const groupByField = buildGroupByDateField({
|
||||
timeZone: undefined,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
getGroupByExpression({ groupByField, columnNameWithQuotes }),
|
||||
).toThrow(
|
||||
'Time zone should be specified for a group by date on Day, Week, Month, Quarter or Year',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('granularity without timezone', () => {
|
||||
it('should return column directly for NONE granularity', () => {
|
||||
const groupByField = buildGroupByDateField({
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.NONE,
|
||||
timeZone: undefined,
|
||||
});
|
||||
|
||||
const result = getGroupByExpression({
|
||||
groupByField,
|
||||
columnNameWithQuotes,
|
||||
});
|
||||
|
||||
expect(result).toBe(columnNameWithQuotes);
|
||||
});
|
||||
|
||||
it('should handle DAY_OF_THE_WEEK without timezone', () => {
|
||||
const groupByField = buildGroupByDateField({
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
|
||||
timeZone: undefined,
|
||||
});
|
||||
|
||||
const result = getGroupByExpression({
|
||||
groupByField,
|
||||
columnNameWithQuotes,
|
||||
});
|
||||
|
||||
expect(result).toContain('TMDay');
|
||||
expect(result).not.toContain('AT TIME ZONE');
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
@@ -28,6 +28,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
|
||||
case CommonQueryRunnerExceptionCode.BAD_REQUEST:
|
||||
case CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY:
|
||||
case CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY:
|
||||
case CommonQueryRunnerExceptionCode.INVALID_TIMEZONE:
|
||||
throw new UserInputError(error);
|
||||
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
|
||||
throw new AuthenticationError(error);
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
|
||||
case CommonQueryRunnerExceptionCode.BAD_REQUEST:
|
||||
case CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY:
|
||||
case CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY:
|
||||
case CommonQueryRunnerExceptionCode.INVALID_TIMEZONE:
|
||||
throw new BadRequestException(error.message);
|
||||
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
|
||||
throw new NotFoundException('Record not found');
|
||||
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
|
||||
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';
|
||||
import { formatColumnNameAsAlias } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util';
|
||||
import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { getGroupByExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-expression.util';
|
||||
import { isGroupByDateField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-date-field.util';
|
||||
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
|
||||
import { formatColumnNameAsAlias } from 'src/engine/api/common/common-query-runners/utils/remove-quote.util';
|
||||
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
|
||||
|
||||
export const getGroupByDefinitions = ({
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE,
|
||||
IANA_TIME_ZONES,
|
||||
} 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/common/common-query-runners/types/group-by-field.types';
|
||||
import { isGroupByDateField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-date-field.util';
|
||||
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
|
||||
|
||||
const VALID_IANA_TIMEZONES = new Set(IANA_TIME_ZONES);
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
shouldUseTimeZone &&
|
||||
!timeZoneIsNotProvided &&
|
||||
!VALID_IANA_TIMEZONES.has(groupByField.timeZone!)
|
||||
) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid timezone: ${groupByField.timeZone}`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_TIMEZONE,
|
||||
{ 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
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { getGroupByExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-expression.util';
|
||||
import { isGroupByDateField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-date-field.util';
|
||||
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/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
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
type GroupByDateField,
|
||||
type GroupByField,
|
||||
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
|
||||
export const isGroupByDateField = (
|
||||
groupByField: GroupByField,
|
||||
): groupByField is GroupByDateField => {
|
||||
return (
|
||||
'dateGranularity' in groupByField &&
|
||||
!('nestedFieldMetadata' in groupByField)
|
||||
);
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
type GroupByField,
|
||||
type GroupByRelationField,
|
||||
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
|
||||
export const isGroupByRelationField = (
|
||||
groupByField: GroupByField,
|
||||
): groupByField is GroupByRelationField => {
|
||||
return 'nestedFieldMetadata' in groupByField;
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
const removeQuotes = (string: string): string => {
|
||||
return string.replace(/["']/g, '');
|
||||
};
|
||||
|
||||
export const formatColumnNameAsAlias = (
|
||||
columnNameWithQuotes: string,
|
||||
): string => {
|
||||
return removeQuotes(columnNameWithQuotes).replace(/\./g, '_');
|
||||
};
|
||||
Reference in New Issue
Block a user