[AI] Add group_by_* database tools and centralize groupBy validation (#19406)
closes https://discord.com/channels/1130383047699738754/1488990242873806868 https://github.com/user-attachments/assets/2b2bbfba-3fa6-4114-9a26-96a61599d748 <img width="729" height="1283" alt="CleanShot 2026-04-07 at 20 43 06" src="https://github.com/user-attachments/assets/815efb97-81a0-44ea-8d79-b3ce7d5b00b6" /> <img width="708" height="1266" alt="CleanShot 2026-04-07 at 20 40 13" src="https://github.com/user-attachments/assets/692366bc-b629-4d9f-b6b8-ab670d5ad046" /> <img width="665" height="3524" alt="CleanShot 2026-04-07 at 20 42 00" src="https://github.com/user-attachments/assets/5e844e0f-7835-47a8-9d20-a5baddc0992d" />
This commit is contained in:
+131
@@ -1,9 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
AggregateOperations,
|
||||
type RestrictedFieldsPermissions,
|
||||
} 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 {
|
||||
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 { validateAndTransformGroupByFieldsOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-group-by-fields-or-throw.util';
|
||||
import {
|
||||
type AggregationField,
|
||||
getAvailableAggregationsFromObjectFields,
|
||||
} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
import {
|
||||
ObjectRecordGroupByForAtomicField,
|
||||
ObjectRecordGroupByForCompositeField,
|
||||
ObjectRecordGroupByForDateField,
|
||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
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';
|
||||
import { resolveAggregateFieldKey } from 'src/engine/core-modules/record-crud/utils/resolve-aggregate-field-key.util';
|
||||
|
||||
@Injectable()
|
||||
export class GroupByArgProcessorService {
|
||||
@@ -30,4 +52,113 @@ export class GroupByArgProcessorService {
|
||||
|
||||
return [groupBy];
|
||||
}
|
||||
|
||||
validateAndTransformGroupByFieldsOrThrow({
|
||||
groupBy,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
groupBy: Array<
|
||||
| ObjectRecordGroupByForAtomicField
|
||||
| ObjectRecordGroupByForCompositeField
|
||||
| ObjectRecordGroupByForDateField
|
||||
>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}): GroupByField[] {
|
||||
return validateAndTransformGroupByFieldsOrThrow({
|
||||
groupBy,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
}
|
||||
|
||||
getAvailableAggregations({
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
restrictedFields,
|
||||
}: {
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
restrictedFields?: RestrictedFieldsPermissions;
|
||||
}): Record<string, AggregationField> {
|
||||
const objectFields = findManyFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityIds: flatObjectMetadata.fieldIds,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
}).filter((field) => restrictedFields?.[field.id]?.canRead !== false);
|
||||
|
||||
return getAvailableAggregationsFromObjectFields(objectFields);
|
||||
}
|
||||
|
||||
validateAggregateFieldKeysOrThrow({
|
||||
aggregateFieldKeys,
|
||||
availableAggregations,
|
||||
}: {
|
||||
aggregateFieldKeys: string[];
|
||||
availableAggregations: Record<string, AggregationField>;
|
||||
}): void {
|
||||
const invalidAggregateFieldKeys = aggregateFieldKeys.filter(
|
||||
(aggregateFieldKey) =>
|
||||
!isDefined(availableAggregations[aggregateFieldKey]),
|
||||
);
|
||||
|
||||
if (invalidAggregateFieldKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Unknown aggregate field(s): ${invalidAggregateFieldKeys.join(', ')}`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
resolveToolAggregateFieldKeyOrThrow({
|
||||
aggregateOperation,
|
||||
aggregateFieldName,
|
||||
availableAggregations,
|
||||
}: {
|
||||
aggregateOperation: keyof typeof AggregateOperations;
|
||||
aggregateFieldName?: string;
|
||||
availableAggregations: Record<string, AggregationField>;
|
||||
}): string {
|
||||
if (aggregateOperation === AggregateOperations.COUNT) {
|
||||
if (aggregateFieldName) {
|
||||
throw new CommonQueryRunnerException(
|
||||
'aggregateFieldName is not supported for COUNT operation',
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
return 'totalCount';
|
||||
}
|
||||
|
||||
if (!aggregateFieldName) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`aggregateFieldName is required for ${aggregateOperation} operation`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const aggregateFieldKey = resolveAggregateFieldKey(
|
||||
aggregateOperation,
|
||||
aggregateFieldName,
|
||||
availableAggregations,
|
||||
);
|
||||
|
||||
if (!aggregateFieldKey) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`No aggregation available for ${aggregateOperation} on field "${aggregateFieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
return aggregateFieldKey;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type CompositeFieldGroupByDefinition = Record<string, boolean>;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
type FirstDayOfTheWeek,
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export type DateFieldGroupByDefinition = {
|
||||
granularity: ObjectRecordGroupByDateGranularity;
|
||||
weekStartDay?: FirstDayOfTheWeek;
|
||||
timeZone?: string;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/types/composite-field-group-by-definition.type';
|
||||
import { type DateFieldGroupByDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/types/date-field-group-by-definition.type';
|
||||
|
||||
export type FieldGroupByDefinition =
|
||||
| boolean
|
||||
| CompositeFieldGroupByDefinition
|
||||
| DateFieldGroupByDefinition
|
||||
| undefined;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
import { type DateFieldGroupByDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/types/date-field-group-by-definition.type';
|
||||
|
||||
const GROUP_BY_DATE_GRANULARITIES = new Set<string>(
|
||||
Object.values(ObjectRecordGroupByDateGranularity),
|
||||
);
|
||||
|
||||
export const isGroupByDateFieldDefinition = (
|
||||
fieldGroupByDefinition: unknown,
|
||||
): fieldGroupByDefinition is DateFieldGroupByDefinition => {
|
||||
if (!isPlainObject(fieldGroupByDefinition)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('granularity' in fieldGroupByDefinition)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const granularity = fieldGroupByDefinition.granularity;
|
||||
|
||||
return (
|
||||
typeof granularity === 'string' &&
|
||||
GROUP_BY_DATE_GRANULARITIES.has(granularity)
|
||||
);
|
||||
};
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
|
||||
export const isRelationNestedFieldSupportedInGroupBy = ({
|
||||
nestedFieldName,
|
||||
nestedFieldMetadata,
|
||||
}: {
|
||||
nestedFieldName: string;
|
||||
nestedFieldMetadata: FlatFieldMetadata;
|
||||
}): boolean => {
|
||||
if (nestedFieldName === 'id') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isFlatFieldMetadataSupportedInGroupBy(nestedFieldMetadata);
|
||||
};
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
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 { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
|
||||
import { validateAndTransformRelationGroupByFieldOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-relation-group-by-field-or-throw.util';
|
||||
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
|
||||
import {
|
||||
ObjectRecordGroupByForAtomicField,
|
||||
ObjectRecordGroupByForCompositeField,
|
||||
ObjectRecordGroupByForDateField,
|
||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
const getFieldMetadataForGroupByOrThrow = ({
|
||||
fieldName,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
fieldName: string;
|
||||
fieldIdByName: Record<string, string>;
|
||||
fieldIdByJoinColumnName: Record<string, string>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}): FlatFieldMetadata => {
|
||||
const fieldMetadataId =
|
||||
fieldIdByName[fieldName] || fieldIdByJoinColumnName[fieldName];
|
||||
const fieldMetadata = fieldMetadataId
|
||||
? findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldMetadataId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(fieldMetadata) || !isDefined(fieldMetadataId)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Unidentified field in groupBy: ${fieldName}`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
return fieldMetadata;
|
||||
};
|
||||
|
||||
const validateAndTransformCompositeGroupByDefinitionOrThrow = ({
|
||||
fieldName,
|
||||
fieldMetadata,
|
||||
fieldGroupByDefinition,
|
||||
groupByFields,
|
||||
}: {
|
||||
fieldName: string;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
fieldGroupByDefinition: Record<string, unknown>;
|
||||
groupByFields: GroupByField[];
|
||||
}) => {
|
||||
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Field "${fieldName}" does not support nested subfields in groupBy`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const supportedCompositeSubFields = getGroupableSubFieldsForCompositeType(
|
||||
fieldMetadata.type,
|
||||
);
|
||||
|
||||
validateSingleKeyForGroupByOrThrow({
|
||||
groupByKeys: Object.keys(fieldGroupByDefinition),
|
||||
errorMessage:
|
||||
'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput',
|
||||
});
|
||||
|
||||
for (const subFieldName of Object.keys(fieldGroupByDefinition)) {
|
||||
if (
|
||||
isCompositeFieldMetadataType(fieldMetadata.type) &&
|
||||
!supportedCompositeSubFields?.includes(subFieldName)
|
||||
) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Composite subfield "${subFieldName}" is not supported in groupBy for "${fieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldGroupByDefinition[subFieldName] !== true) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Composite subfield "${subFieldName}" must be set to true in groupBy for "${fieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
subFieldName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndTransformSingleGroupByFieldOrThrow = ({
|
||||
fieldNames,
|
||||
fieldName,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
groupByFields,
|
||||
}: {
|
||||
fieldNames: Record<string, unknown>;
|
||||
fieldName: string;
|
||||
fieldIdByName: Record<string, string>;
|
||||
fieldIdByJoinColumnName: Record<string, string>;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
groupByFields: GroupByField[];
|
||||
}) => {
|
||||
const fieldMetadata = getFieldMetadataForGroupByOrThrow({
|
||||
fieldName,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Field "${fieldName}" is not supported in groupBy`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const fieldGroupByDefinition = fieldNames[fieldName];
|
||||
const isObjectFieldGroupByDefinition = isPlainObject(fieldGroupByDefinition);
|
||||
const isGroupByRelationField =
|
||||
isMorphOrRelationFlatFieldMetadata(fieldMetadata) &&
|
||||
isObjectFieldGroupByDefinition &&
|
||||
!isGroupByDateFieldDefinition(fieldGroupByDefinition);
|
||||
const isGroupByRelationJoinColumnField =
|
||||
isMorphOrRelationFlatFieldMetadata(fieldMetadata) &&
|
||||
fieldGroupByDefinition === true &&
|
||||
isDefined(fieldIdByJoinColumnName[fieldName]);
|
||||
|
||||
if (isGroupByRelationField || isGroupByRelationJoinColumnField) {
|
||||
const normalizedFieldNames = isGroupByRelationJoinColumnField
|
||||
? { ...fieldNames, [fieldName]: { id: true } }
|
||||
: fieldNames;
|
||||
|
||||
validateAndTransformRelationGroupByFieldOrThrow({
|
||||
fieldNames: normalizedFieldNames,
|
||||
fieldName,
|
||||
fieldMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(fieldMetadata.type === FieldMetadataType.DATE ||
|
||||
fieldMetadata.type === FieldMetadataType.DATE_TIME) &&
|
||||
isGroupByDateFieldDefinition(fieldGroupByDefinition)
|
||||
) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
dateGranularity: fieldGroupByDefinition.granularity,
|
||||
weekStartDay: fieldGroupByDefinition.weekStartDay,
|
||||
timeZone: fieldGroupByDefinition.timeZone,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isObjectFieldGroupByDefinition && 'unnest' in fieldGroupByDefinition) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
subFieldName: undefined,
|
||||
shouldUnnest: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldGroupByDefinition === true) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
subFieldName: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isObjectFieldGroupByDefinition) {
|
||||
validateAndTransformCompositeGroupByDefinitionOrThrow({
|
||||
fieldName,
|
||||
fieldMetadata,
|
||||
fieldGroupByDefinition,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid groupBy definition for field "${fieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
};
|
||||
|
||||
export const validateAndTransformGroupByFieldsOrThrow = ({
|
||||
groupBy,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
groupBy: Array<
|
||||
| ObjectRecordGroupByForAtomicField
|
||||
| ObjectRecordGroupByForCompositeField
|
||||
| ObjectRecordGroupByForDateField
|
||||
>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}): GroupByField[] => {
|
||||
const groupByFields: GroupByField[] = [];
|
||||
|
||||
const { fieldIdByName, fieldIdByJoinColumnName } =
|
||||
buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
|
||||
for (const fieldNames of groupBy) {
|
||||
validateSingleKeyForGroupByOrThrow({
|
||||
groupByKeys: Object.keys(fieldNames),
|
||||
errorMessage:
|
||||
'You cannot provide multiple fields in one GroupByInput, split them into multiple GroupByInput',
|
||||
});
|
||||
|
||||
for (const fieldName of Object.keys(fieldNames)) {
|
||||
validateAndTransformSingleGroupByFieldOrThrow({
|
||||
fieldNames,
|
||||
fieldName,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
groupByFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groupByFields;
|
||||
};
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/types/composite-field-group-by-definition.type';
|
||||
import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
|
||||
import { isRelationNestedFieldSupportedInGroupBy } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-relation-nested-field-supported-in-group-by.util';
|
||||
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
|
||||
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 { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
const getNestedFieldMetadataDetails = ({
|
||||
fieldNames,
|
||||
fieldName,
|
||||
fieldMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
fieldNames: Record<string, unknown>;
|
||||
fieldName: string;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}) => {
|
||||
const nestedFieldGroupByDefinitions = fieldNames[fieldName];
|
||||
|
||||
if (!isPlainObject(nestedFieldGroupByDefinitions)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid groupBy definition for relation field "${fieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Relation target object metadata id not found for field ${fieldMetadata.name}`,
|
||||
CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldMetadata.relationTargetObjectMetadataId,
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(targetObjectMetadata)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Target object metadata not found for relation field ${fieldMetadata.name}`,
|
||||
CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const nestedFieldNames = Object.keys(nestedFieldGroupByDefinitions);
|
||||
|
||||
validateSingleKeyForGroupByOrThrow({
|
||||
groupByKeys: nestedFieldNames,
|
||||
errorMessage:
|
||||
'You cannot provide multiple nested fields in one relation GroupByInput, split them into multiple GroupByInput',
|
||||
});
|
||||
|
||||
const nestedFieldName = nestedFieldNames[0];
|
||||
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
targetObjectMetadata,
|
||||
);
|
||||
|
||||
const nestedFieldMetadataId = fieldIdByName[nestedFieldName];
|
||||
const nestedFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: nestedFieldMetadataId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isRelationNestedFieldSupportedInGroupBy({
|
||||
nestedFieldName,
|
||||
nestedFieldMetadata,
|
||||
})
|
||||
) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Nested field "${nestedFieldName}" is not supported in groupBy`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (nestedFieldMetadata.type === FieldMetadataType.RELATION) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Cannot group by a relation field of the relation field: "${nestedFieldName}" is a relation field of "${targetObjectMetadata.nameSingular}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const nestedFieldGroupByDefinition =
|
||||
nestedFieldGroupByDefinitions[nestedFieldName];
|
||||
|
||||
return {
|
||||
nestedFieldGroupByDefinition,
|
||||
nestedFieldMetadata,
|
||||
nestedFieldName,
|
||||
};
|
||||
};
|
||||
|
||||
const validateAndTransformNestedCompositeFieldOrThrow = ({
|
||||
nestedFieldGroupByDefinition,
|
||||
nestedFieldName,
|
||||
fieldMetadata,
|
||||
nestedFieldMetadata,
|
||||
groupByFields,
|
||||
}: {
|
||||
nestedFieldGroupByDefinition: unknown;
|
||||
nestedFieldName: string;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
nestedFieldMetadata: FlatFieldMetadata;
|
||||
groupByFields: GroupByField[];
|
||||
}) => {
|
||||
if (!isPlainObject(nestedFieldGroupByDefinition)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Composite field "${nestedFieldName}" requires a subfield to be specified`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const compositeSubFields = Object.keys(nestedFieldGroupByDefinition);
|
||||
|
||||
validateSingleKeyForGroupByOrThrow({
|
||||
groupByKeys: compositeSubFields,
|
||||
errorMessage:
|
||||
'You cannot provide multiple composite subfields in one GroupByInput, split them into multiple GroupByInput',
|
||||
});
|
||||
|
||||
const nestedSubFieldName = compositeSubFields[0];
|
||||
const supportedCompositeSubFields = getGroupableSubFieldsForCompositeType(
|
||||
nestedFieldMetadata.type,
|
||||
);
|
||||
|
||||
if (
|
||||
!supportedCompositeSubFields?.includes(nestedSubFieldName) ||
|
||||
nestedFieldGroupByDefinition[nestedSubFieldName] !== true
|
||||
) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Composite subfield "${nestedSubFieldName}" is not supported in groupBy for "${nestedFieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
nestedFieldMetadata,
|
||||
nestedSubFieldName,
|
||||
});
|
||||
};
|
||||
|
||||
export const validateAndTransformRelationGroupByFieldOrThrow = ({
|
||||
fieldNames,
|
||||
fieldName,
|
||||
fieldMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
groupByFields,
|
||||
}: {
|
||||
fieldNames: Record<string, unknown>;
|
||||
fieldName: string;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
groupByFields: GroupByField[];
|
||||
}) => {
|
||||
const { nestedFieldGroupByDefinition, nestedFieldMetadata, nestedFieldName } =
|
||||
getNestedFieldMetadataDetails({
|
||||
fieldNames,
|
||||
fieldName,
|
||||
fieldMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (
|
||||
(nestedFieldMetadata.type === FieldMetadataType.DATE ||
|
||||
nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
|
||||
isGroupByDateFieldDefinition(nestedFieldGroupByDefinition)
|
||||
) {
|
||||
const dateFieldDefinition = nestedFieldGroupByDefinition;
|
||||
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
nestedFieldMetadata,
|
||||
dateGranularity: dateFieldDefinition.granularity,
|
||||
weekStartDay: dateFieldDefinition.weekStartDay,
|
||||
timeZone: dateFieldDefinition.timeZone,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
|
||||
validateAndTransformNestedCompositeFieldOrThrow({
|
||||
nestedFieldGroupByDefinition,
|
||||
nestedFieldName,
|
||||
fieldMetadata,
|
||||
nestedFieldMetadata,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (nestedFieldGroupByDefinition === true) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
nestedFieldMetadata,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid groupBy definition for nested field "${fieldName}.${nestedFieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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';
|
||||
|
||||
export const validateSingleKeyForGroupByOrThrow = ({
|
||||
groupByKeys,
|
||||
errorMessage,
|
||||
}: {
|
||||
groupByKeys: string[];
|
||||
errorMessage: string;
|
||||
}): void => {
|
||||
if (groupByKeys.length > 1) {
|
||||
throw new CommonQueryRunnerException(
|
||||
errorMessage,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
};
|
||||
+21
-10
@@ -41,7 +41,6 @@ import {
|
||||
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 { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.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';
|
||||
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
|
||||
@@ -94,12 +93,13 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
objectMetadataNameSingular,
|
||||
);
|
||||
|
||||
const groupByFields = parseGroupByArgs(
|
||||
args,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
const groupByFields =
|
||||
this.groupByArgProcessor.validateAndTransformGroupByFieldsOrThrow({
|
||||
groupBy: args.groupBy,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
@@ -393,9 +393,20 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
}
|
||||
|
||||
async validate(
|
||||
_args: CommonInput<GroupByQueryArgs>,
|
||||
_queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): Promise<void> {}
|
||||
args: CommonInput<GroupByQueryArgs>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): Promise<void> {
|
||||
const normalizedGroupBy = this.groupByArgProcessor.process({
|
||||
groupBy: args.groupBy,
|
||||
});
|
||||
|
||||
this.groupByArgProcessor.validateAndTransformGroupByFieldsOrThrow({
|
||||
groupBy: normalizedGroupBy,
|
||||
flatObjectMetadata: queryRunnerContext.flatObjectMetadata,
|
||||
flatObjectMetadataMaps: queryRunnerContext.flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps: queryRunnerContext.flatFieldMetadataMaps,
|
||||
});
|
||||
}
|
||||
|
||||
async computeArgs(
|
||||
args: CommonInput<GroupByQueryArgs>,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { CommonArgsProcessors } from 'src/engine/api/common/common-args-processors/common-args-processors';
|
||||
import { GroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service';
|
||||
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
|
||||
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
|
||||
import { CommonQueryRunners } from 'src/engine/api/common/common-query-runners/common-query-runners';
|
||||
@@ -53,6 +54,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
CommonResultGettersService,
|
||||
GroupByWithRecordsService,
|
||||
],
|
||||
exports: [...CommonQueryRunners],
|
||||
exports: [...CommonQueryRunners, GroupByArgProcessorService],
|
||||
})
|
||||
export class CoreCommonApiModule {}
|
||||
|
||||
Reference in New Issue
Block a user