[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
-1
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
type FirstDayOfTheWeek,
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export type DateFieldGroupByDefinition = {
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
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 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
|
||||
+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;
|
||||
};
|
||||
+98
-63
@@ -1,21 +1,20 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
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 {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} 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';
|
||||
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 { 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';
|
||||
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 { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.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';
|
||||
@@ -33,22 +32,37 @@ const getNestedFieldMetadataDetails = ({
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}) => {
|
||||
const nestedFieldGroupByDefinitions = fieldNames[fieldName] as
|
||||
| Record<string, boolean>
|
||||
| Record<string, CompositeFieldGroupByDefinition>
|
||||
| Record<string, DateFieldGroupByDefinition>;
|
||||
const nestedFieldGroupByDefinitions = fieldNames[fieldName];
|
||||
|
||||
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
|
||||
throw new UserInputError(
|
||||
`Relation target object metadata id not found for field ${fieldMetadata.name}`,
|
||||
if (!isPlainObject(nestedFieldGroupByDefinitions)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid groupBy definition for relation field "${fieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
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({
|
||||
@@ -70,16 +84,31 @@ const getNestedFieldMetadataDetails = ({
|
||||
});
|
||||
|
||||
if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
throw new CommonQueryRunnerException(
|
||||
`Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`,
|
||||
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
|
||||
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 UserInputError(
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,57 +122,59 @@ const getNestedFieldMetadataDetails = ({
|
||||
};
|
||||
};
|
||||
|
||||
const handleNestedCompositeField = ({
|
||||
const validateAndTransformNestedCompositeFieldOrThrow = ({
|
||||
nestedFieldGroupByDefinition,
|
||||
nestedFieldName,
|
||||
fieldMetadata,
|
||||
nestedFieldMetadata,
|
||||
groupByFields,
|
||||
}: {
|
||||
nestedFieldGroupByDefinition: CompositeFieldGroupByDefinition;
|
||||
nestedFieldGroupByDefinition: unknown;
|
||||
nestedFieldName: string;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
nestedFieldMetadata: FlatFieldMetadata;
|
||||
groupByFields: GroupByField[];
|
||||
}) => {
|
||||
if (
|
||||
typeof nestedFieldGroupByDefinition === 'object' &&
|
||||
nestedFieldGroupByDefinition !== null
|
||||
) {
|
||||
const compositeSubFields = Object.keys(
|
||||
nestedFieldGroupByDefinition as Record<string, unknown>,
|
||||
if (!isPlainObject(nestedFieldGroupByDefinition)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Composite field "${nestedFieldName}" requires a subfield to be specified`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
|
||||
validateSingleKeyForGroupByOrThrow({
|
||||
groupByKeys: compositeSubFields,
|
||||
errorMessage:
|
||||
'You cannot provide multiple composite subfields in one GroupByInput, split them into multiple GroupByInput',
|
||||
});
|
||||
|
||||
const nestedSubFieldName = compositeSubFields[0];
|
||||
|
||||
if (
|
||||
(nestedFieldGroupByDefinition as Record<string, boolean>)[
|
||||
nestedSubFieldName
|
||||
] === true
|
||||
) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
nestedFieldMetadata,
|
||||
nestedSubFieldName,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Composite field "${nestedFieldName}" requires a subfield to be specified`,
|
||||
GraphqlQueryRunnerExceptionCode.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 parseGroupByRelationField = ({
|
||||
export const validateAndTransformRelationGroupByFieldOrThrow = ({
|
||||
fieldNames,
|
||||
fieldName,
|
||||
fieldMetadata,
|
||||
@@ -167,7 +198,6 @@ export const parseGroupByRelationField = ({
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
// Handle date fields in nested relations
|
||||
if (
|
||||
(nestedFieldMetadata.type === FieldMetadataType.DATE ||
|
||||
nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
|
||||
@@ -186,19 +216,18 @@ export const parseGroupByRelationField = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle composite fields in nested relations
|
||||
if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
|
||||
handleNestedCompositeField({
|
||||
nestedFieldGroupByDefinition:
|
||||
nestedFieldGroupByDefinition as CompositeFieldGroupByDefinition,
|
||||
validateAndTransformNestedCompositeFieldOrThrow({
|
||||
nestedFieldGroupByDefinition,
|
||||
nestedFieldName,
|
||||
fieldMetadata,
|
||||
nestedFieldMetadata,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular nested fields
|
||||
if (nestedFieldGroupByDefinition === true) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
@@ -207,4 +236,10 @@ export const parseGroupByRelationField = ({
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid groupBy definition for nested field "${fieldName}.${nestedFieldName}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
};
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
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,
|
||||
@@ -12,9 +12,9 @@ export const validateSingleKeyForGroupByOrThrow = ({
|
||||
errorMessage: string;
|
||||
}): void => {
|
||||
if (groupByKeys.length > 1) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
throw new CommonQueryRunnerException(
|
||||
errorMessage,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
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 {}
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { isDefined } from 'class-validator';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
import { type FieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type';
|
||||
|
||||
export const isGroupByDateFieldDefinition = (
|
||||
fieldGroupByDefinition: FieldGroupByDefinition,
|
||||
): fieldGroupByDefinition is {
|
||||
granularity: ObjectRecordGroupByDateGranularity;
|
||||
} => {
|
||||
if (
|
||||
typeof fieldGroupByDefinition !== 'object' ||
|
||||
!isDefined(fieldGroupByDefinition)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!('granularity' in fieldGroupByDefinition)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const granularity = fieldGroupByDefinition.granularity;
|
||||
|
||||
return (
|
||||
isDefined(granularity) &&
|
||||
typeof granularity === 'string' &&
|
||||
Object.values(ObjectRecordGroupByDateGranularity).includes(
|
||||
granularity as ObjectRecordGroupByDateGranularity,
|
||||
)
|
||||
);
|
||||
};
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
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/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';
|
||||
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 { 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';
|
||||
|
||||
export const parseGroupByArgs = (
|
||||
args: GroupByResolverArgs,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
): GroupByField[] => {
|
||||
const groupByFieldNames = args.groupBy;
|
||||
|
||||
const groupByFields: GroupByField[] = [];
|
||||
|
||||
const { fieldIdByName, fieldIdByJoinColumnName } =
|
||||
buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
|
||||
for (const fieldNames of groupByFieldNames) {
|
||||
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)) {
|
||||
const fieldMetadataId =
|
||||
fieldIdByName[fieldName] || fieldIdByJoinColumnName[fieldName];
|
||||
const fieldMetadata = fieldMetadataId
|
||||
? findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldMetadataId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(fieldMetadata) || !isDefined(fieldMetadataId)) {
|
||||
throw new Error(`Unidentified field in groupBy: ${fieldName}`);
|
||||
}
|
||||
|
||||
const isGroupByRelationField =
|
||||
isMorphOrRelationFlatFieldMetadata(fieldMetadata) &&
|
||||
typeof fieldNames[fieldName] === 'object' &&
|
||||
fieldNames[fieldName] !== null &&
|
||||
!isGroupByDateFieldDefinition(fieldNames[fieldName]);
|
||||
|
||||
// Handle relation fields
|
||||
if (isGroupByRelationField) {
|
||||
parseGroupByRelationField({
|
||||
fieldNames,
|
||||
fieldName,
|
||||
fieldMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle date fields
|
||||
if (
|
||||
fieldMetadata.type === FieldMetadataType.DATE ||
|
||||
fieldMetadata.type === FieldMetadataType.DATE_TIME
|
||||
) {
|
||||
const fieldGroupByDefinition = fieldNames[fieldName];
|
||||
|
||||
const shouldGroupByDateGranularity = isGroupByDateFieldDefinition(
|
||||
fieldGroupByDefinition,
|
||||
);
|
||||
|
||||
if (shouldGroupByDateGranularity) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
dateGranularity: fieldGroupByDefinition.granularity,
|
||||
weekStartDay: fieldGroupByDefinition.weekStartDay,
|
||||
timeZone: fieldGroupByDefinition.timeZone,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle array unnest fields
|
||||
if (
|
||||
typeof fieldNames[fieldName] === 'object' &&
|
||||
fieldNames[fieldName] !== null &&
|
||||
'unnest' in fieldNames[fieldName]
|
||||
) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
subFieldName: undefined,
|
||||
shouldUnnest: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle regular fields and composite fields
|
||||
if (fieldNames[fieldName] === true) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
subFieldName: undefined,
|
||||
});
|
||||
continue;
|
||||
} else if (typeof fieldNames[fieldName] === 'object') {
|
||||
validateSingleKeyForGroupByOrThrow({
|
||||
groupByKeys: Object.keys(fieldNames[fieldName]),
|
||||
errorMessage:
|
||||
'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput',
|
||||
});
|
||||
|
||||
for (const subFieldName of Object.keys(fieldNames[fieldName])) {
|
||||
if (
|
||||
(fieldNames[fieldName] as Record<string, boolean>)[subFieldName] ===
|
||||
true
|
||||
) {
|
||||
groupByFields.push({
|
||||
fieldMetadata,
|
||||
subFieldName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groupByFields;
|
||||
};
|
||||
+2
-2
@@ -12,6 +12,7 @@ import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-sch
|
||||
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
|
||||
import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage';
|
||||
import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util';
|
||||
import { isCompositePropertySupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-property-supported-in-group-by.util';
|
||||
import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util';
|
||||
|
||||
@Injectable()
|
||||
@@ -54,8 +55,7 @@ export class CompositeFieldMetadataGroupByGqlInputTypeGenerator {
|
||||
throw new Error('Relation fields are not supported in composite types');
|
||||
}
|
||||
|
||||
// Skip hidden fields
|
||||
if (property.hidden === true) {
|
||||
if (!isCompositePropertySupportedInGroupBy(property)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -20,6 +20,7 @@ import { type SchemaGenerationContext } from 'src/engine/api/graphql/workspace-s
|
||||
import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util';
|
||||
import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-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 { 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';
|
||||
@@ -62,6 +63,10 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
|
||||
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
|
||||
|
||||
for (const fieldMetadata of fields) {
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const generatedField = isMorphOrRelationFlatFieldMetadata(fieldMetadata)
|
||||
? this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldGroupByInputType(
|
||||
fieldMetadata,
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const MCP_SERVER_INSTRUCTIONS =
|
||||
'Twenty CRM MCP Server. Follow this workflow: (1) get_tool_catalog to discover tools, (2) learn_tools to get input schemas, (3) execute_tool to run them. Never guess tool names — always start with get_tool_catalog. Use load_skills for guidance on complex tasks like workflow or dashboard building.';
|
||||
'Twenty CRM MCP Server. Follow this workflow: (1) get_tool_catalog to discover tools, (2) learn_tools to get input schemas, (3) execute_tool to run them. Never guess tool names — always start with get_tool_catalog. Use load_skills for guidance on complex tasks like workflow or dashboard building. For comparative/grouped analytics (by/per/top/most/least/average/total/ranking), use group_by tools, and use find tools for record retrieval; for multiple metrics, run multiple group_by calls and merge results.';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from 'twenty-shared/constants';
|
||||
|
||||
import { GroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service';
|
||||
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
|
||||
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
|
||||
import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util';
|
||||
@@ -19,6 +20,7 @@ import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest
|
||||
export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
constructor(
|
||||
private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService,
|
||||
private readonly groupByArgProcessor: GroupByArgProcessorService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -84,6 +86,17 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
const groupBy = parseGroupByRestRequest(request);
|
||||
const includeRecords = parseIncludeRecordsSampleRestRequest(request);
|
||||
const aggregateFields = parseAggregateFieldsRestRequest(request);
|
||||
const availableAggregations =
|
||||
this.groupByArgProcessor.getAvailableAggregations({
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
this.groupByArgProcessor.validateAggregateFieldKeysOrThrow({
|
||||
aggregateFieldKeys: Object.keys(aggregateFields),
|
||||
availableAggregations,
|
||||
});
|
||||
|
||||
const limit = parseLimitRestRequest(
|
||||
request,
|
||||
DEFAULT_NUMBER_OF_GROUPS_LIMIT,
|
||||
|
||||
Reference in New Issue
Block a user