Files
twenty/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts
T
Weiko 1607aebcc6 Deprecate object metadata maps in favor of flat entities (#16080)
## Context
Deprecating the old objectMetadataMap type in favour of split flat
entities to match with our new caching.
In the long run, trying to achieve:
- Better performance through caching
- Consistent data access patterns across the codebase
- Reduced database queries

Now that everything is based on flat entities, which are cached, we can
finish the refactoring of workspace context cache which should already
improve performances.
Then the last step will be to consume that new cache in the new global
datasource to get rid of the many workspace datasources stored in the
server
2025-11-27 13:43:34 +01:00

403 lines
14 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import {
CompositeFieldSubFieldName,
PartialFieldMetadataItemOption,
RecordFilterGroupLogicalOperator,
} from 'twenty-shared/types';
import {
assertIsDefinedOrThrow,
combineFilters,
computeRecordGqlOperationFilter,
convertViewFilterValueToString,
getFilterTypeFromFieldType,
turnAnyFieldFilterIntoRecordGqlFilter,
} from 'twenty-shared/utils';
import { ObjectLiteral } from 'typeorm';
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { getGroupByDefinitions } from 'src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util';
import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import {
CommonExtendedInput,
CommonInput,
CommonQueryNames,
GroupByQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type';
import { GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
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 { 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 { 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';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { 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 { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util';
@Injectable()
export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerService<
GroupByQueryArgs,
CommonGroupByOutputItem[]
> {
constructor(
private readonly viewFilterService: ViewFilterService,
private readonly viewFilterGroupService: ViewFilterGroupService,
private readonly viewService: ViewService,
private readonly groupByWithRecordsService: GroupByWithRecordsService,
) {
super();
}
protected readonly operationName = CommonQueryNames.GROUP_BY;
async run(
args: CommonExtendedInput<GroupByQueryArgs>,
queryRunnerContext: CommonExtendedQueryRunnerContext,
): Promise<CommonGroupByOutputItem[]> {
const {
repository,
commonQueryParser,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
authContext,
} = queryRunnerContext;
const objectMetadataNameSingular = flatObjectMetadata.nameSingular;
let queryBuilder = repository.createQueryBuilder(
objectMetadataNameSingular,
);
const groupByFields = parseGroupByArgs(
args,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
const objectAlias = getObjectAlias(flatObjectMetadata);
this.addJoinForGroupByOnRelationFields({
queryBuilder,
groupByFields,
objectAlias,
});
let appliedFilters = args.filter ?? ({} as ObjectRecordFilter);
await this.addFiltersToQueryBuilder({
args,
appliedFilters,
queryBuilder,
flatObjectMetadata,
flatFieldMetadataMaps,
workspaceId: authContext.workspace.id,
commonQueryParser,
});
const queryBuilderWithFiltersAndWithoutGroupBy = queryBuilder.clone();
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: args.selectedFieldsResult.aggregate,
queryBuilder,
objectMetadataNameSingular,
});
const groupByDefinitions = getGroupByDefinitions({
groupByFields,
objectMetadataNameSingular,
});
groupByDefinitions.forEach((groupByColumn, index) => {
queryBuilder.addSelect(groupByColumn.expression, groupByColumn.alias);
if (index === 0) {
queryBuilder.groupBy(groupByColumn.expression);
} else {
queryBuilder.addGroupBy(groupByColumn.expression);
}
});
commonQueryParser.applyGroupByOrderToBuilder(
queryBuilder,
args.orderBy ?? [],
groupByFields,
);
const shouldIncludeRecords = args.includeRecords ?? false;
if (shouldIncludeRecords) {
return this.groupByWithRecordsService.resolveWithRecords({
queryBuilderWithFiltersAndWithoutGroupBy,
queryBuilderWithGroupBy: queryBuilder,
groupByDefinitions,
selectedFieldsResult: args.selectedFieldsResult,
queryRunnerContext,
orderByForRecords: args.orderByForRecords ?? [],
groupLimit: args.limit,
});
}
return this.resolveWithoutRecords({
queryBuilder,
groupByDefinitions,
selectedFieldsResult: args.selectedFieldsResult,
groupLimit: args.limit,
});
}
async processQueryResult(
queryResult: CommonGroupByOutputItem[],
_flatObjectMetadata: FlatObjectMetadata,
_flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
_flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
_authContext: WorkspaceAuthContext,
): Promise<CommonGroupByOutputItem[]> {
return queryResult;
}
private async addFiltersFromView({
args,
flatObjectMetadata,
flatFieldMetadataMaps,
appliedFilters,
workspaceId,
}: {
args: GroupByQueryArgs;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
appliedFilters: ObjectRecordFilter;
workspaceId: string;
}): Promise<ObjectRecordFilter> {
assertIsDefinedOrThrow(args.viewId);
const viewFilters = await this.viewFilterService.findByViewId(
workspaceId,
args.viewId,
);
const viewFilterGroups = await this.viewFilterGroupService.findByViewId(
workspaceId,
args.viewId,
);
const recordFilters = viewFilters.map((viewFilter) => {
const fieldMetadata =
flatFieldMetadataMaps.byId[viewFilter.fieldMetadataId];
if (!fieldMetadata) {
throw new CommonQueryRunnerException(
`Field metadata not found for field ${viewFilter.fieldMetadataId}`,
CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
);
}
return {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
value: convertViewFilterValueToString(viewFilter.value),
type: getFilterTypeFromFieldType(fieldMetadata.type),
operand: viewFilter.operand,
recordFilterGroupId: viewFilter.viewFilterGroupId,
positionInRecordFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
};
});
const recordFilterGroups = viewFilterGroups.map((viewFilterGroup) => {
return {
id: viewFilterGroup.id,
logicalOperator:
viewFilterGroup.logicalOperator as unknown as RecordFilterGroupLogicalOperator, // TODO - https://github.com/twentyhq/twenty/issues/14746
parentRecordFilterGroupId: viewFilterGroup.parentViewFilterGroupId,
};
});
const fields = getFlatFieldsFromFlatObjectMetadata(
flatObjectMetadata,
flatFieldMetadataMaps,
).map((field) => ({
id: field.id,
name: field.name,
type: field.type,
label: field.label,
options: field.options as PartialFieldMetadataItemOption[],
}));
const filtersFromView = computeRecordGqlOperationFilter({
recordFilters,
recordFilterGroups: recordFilterGroups,
fields,
filterValueDependencies: {},
});
let view: ViewEntity | null = viewFilters[0]?.view;
if (!view) {
view = await this.viewService.findById(args.viewId, workspaceId);
}
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
fields,
filterValue: view?.anyFieldFilterValue ?? '',
});
appliedFilters = combineFilters([
appliedFilters,
filtersFromView,
anyFieldFilter,
]);
return appliedFilters;
}
private async addFiltersToQueryBuilder({
args,
appliedFilters,
queryBuilder,
flatObjectMetadata,
flatFieldMetadataMaps,
workspaceId,
commonQueryParser,
}: {
args: GroupByQueryArgs;
appliedFilters: ObjectRecordFilter;
queryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
workspaceId: string;
commonQueryParser: GraphqlQueryParser;
}): Promise<void> {
const objectMetadataNameSingular = flatObjectMetadata.nameSingular;
if (args.viewId) {
appliedFilters = await this.addFiltersFromView({
args,
flatObjectMetadata,
flatFieldMetadataMaps,
appliedFilters,
workspaceId,
});
}
commonQueryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataNameSingular,
appliedFilters,
);
commonQueryParser.applyDeletedAtToBuilder(queryBuilder, appliedFilters);
}
private async resolveWithoutRecords({
queryBuilder,
groupByDefinitions,
selectedFieldsResult,
groupLimit,
}: {
queryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>;
groupByDefinitions: GroupByDefinition[];
selectedFieldsResult: GraphqlQuerySelectedFieldsResult;
groupLimit?: number;
}): Promise<CommonGroupByOutputItem[]> {
const effectiveGroupLimit = getGroupLimit(groupLimit);
queryBuilder.limit(effectiveGroupLimit);
const result = await queryBuilder.getRawMany();
return formatResultWithGroupByDimensionValues({
groupsResult: result,
groupByDefinitions,
aggregateFieldNames: Object.keys(selectedFieldsResult.aggregate),
});
}
private addJoinForGroupByOnRelationFields({
queryBuilder,
groupByFields,
objectAlias,
}: {
queryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>;
groupByFields: GroupByField[];
objectAlias: string;
}): void {
const joinAliasSet = new Set<string>();
for (const groupByField of groupByFields) {
if (isGroupByRelationField(groupByField)) {
const joinAlias = groupByField.fieldMetadata.name;
if (
!groupByField.fieldMetadata.settings ||
!isMorphOrRelationFlatFieldMetadata(groupByField.fieldMetadata)
) {
throw new CommonQueryRunnerException(
`Field metadata settings are missing or invalid for field ${groupByField.fieldMetadata.name}`,
CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const joinColumnName = formatColumnNameForRelationField(
groupByField.fieldMetadata.name,
groupByField.fieldMetadata.settings,
);
if (!joinAliasSet.has(joinAlias)) {
queryBuilder.leftJoin(
`${objectAlias}.${joinAlias}`,
`${joinAlias}`,
`"${objectAlias}"."${joinColumnName}" = "${joinAlias}"."id"`,
);
joinAliasSet.add(joinAlias);
}
}
}
}
async validate(
_args: CommonInput<GroupByQueryArgs>,
_queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<void> {}
async computeArgs(
args: CommonInput<GroupByQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<GroupByQueryArgs>> {
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
return {
...args,
filter: this.queryRunnerArgsFactory.overrideFilterByFieldMetadata(
args.filter,
flatObjectMetadata,
flatFieldMetadataMaps,
),
};
}
}