import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'class-validator'; import { type OrderByWithGroupBy } from 'twenty-shared/types'; import { type FindOptionsWhere, type ObjectLiteral } from 'typeorm'; import { type ObjectRecordFilter, type ObjectRecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types'; import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser'; import { GraphqlQueryOrderGroupByParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser'; import { GraphqlQueryOrderFieldParser, type OrderByClause, type RelationJoinInfo, } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; import { GraphqlQuerySelectedFieldsParser, type GraphqlQuerySelectedFieldsResult, } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; import { addRelationJoinAliasToQueryBuilder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/add-relation-join-alias.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 { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; export class GraphqlQueryParser { private flatObjectMetadata: FlatObjectMetadata; private flatObjectMetadataMaps: FlatEntityMaps; private flatFieldMetadataMaps: FlatEntityMaps; private filterConditionParser: GraphqlQueryFilterConditionParser; private orderFieldParser: GraphqlQueryOrderFieldParser; private orderGroupByParser: GraphqlQueryOrderGroupByParser; constructor( flatObjectMetadata: FlatObjectMetadata, flatObjectMetadataMaps: FlatEntityMaps, flatFieldMetadataMaps: FlatEntityMaps, ) { this.flatObjectMetadata = flatObjectMetadata; this.flatObjectMetadataMaps = flatObjectMetadataMaps; this.flatFieldMetadataMaps = flatFieldMetadataMaps; this.filterConditionParser = new GraphqlQueryFilterConditionParser( this.flatObjectMetadata, this.flatFieldMetadataMaps, this.flatObjectMetadataMaps, ); this.orderFieldParser = new GraphqlQueryOrderFieldParser( this.flatObjectMetadata, this.flatObjectMetadataMaps, this.flatFieldMetadataMaps, ); this.orderGroupByParser = new GraphqlQueryOrderGroupByParser( this.flatObjectMetadata, this.flatObjectMetadataMaps, this.flatFieldMetadataMaps, ); } public applyFilterToBuilder( // oxlint-disable-next-line typescript/no-explicit-any queryBuilder: WorkspaceSelectQueryBuilder, objectNameSingular: string, recordFilter: Partial, // oxlint-disable-next-line typescript/no-explicit-any ): WorkspaceSelectQueryBuilder { return this.filterConditionParser.parse( queryBuilder, objectNameSingular, recordFilter, ); } public applyDeletedAtToBuilder( // oxlint-disable-next-line typescript/no-explicit-any queryBuilder: WorkspaceSelectQueryBuilder, recordFilter: Partial, // oxlint-disable-next-line typescript/no-explicit-any ): WorkspaceSelectQueryBuilder { if (this.checkForDeletedAtFilter(recordFilter)) { queryBuilder.withDeleted(); } return queryBuilder; } private checkForDeletedAtFilter = ( filter: FindOptionsWhere | FindOptionsWhere[], ): boolean => { if (Array.isArray(filter)) { return filter.some((subFilter) => this.checkForDeletedAtFilter(subFilter), ); } for (const [key, value] of Object.entries(filter)) { if (key === 'deletedAt') { return true; } // Only recurse into boolean-operator wrappers (and / or / not) — those // are transparent w.r.t. which entity owns a deletedAt. Composite // sub-field and relation-traversal nesting refers to a different // entity's deletedAt, which must not widen the root query. if ( (key === 'and' || key === 'or' || key === 'not') && typeof value === 'object' && value !== null && this.checkForDeletedAtFilter(value as FindOptionsWhere) ) { return true; } } return false; }; public applyOrderToBuilder( // oxlint-disable-next-line typescript/no-explicit-any queryBuilder: WorkspaceSelectQueryBuilder, orderBy: ObjectRecordOrderBy | OrderByWithGroupBy, objectNameSingular: string, isForwardPagination = true, ): Record { const parseResult = this.orderFieldParser.parse( orderBy as ObjectRecordOrderBy, objectNameSingular, isForwardPagination, ); for (const joinInfo of parseResult.relationJoins) { addRelationJoinAliasToQueryBuilder({ queryBuilder, parentAlias: objectNameSingular, relationName: joinInfo.joinAlias, }); } queryBuilder.orderBy(parseResult.orderBy); // Return parsed orderBy so caller can add relation columns after setFindOptions return parseResult.orderBy; } public addRelationOrderColumnsToBuilder( // oxlint-disable-next-line typescript/no-explicit-any queryBuilder: WorkspaceSelectQueryBuilder, parsedOrderBy: Record, objectNameSingular: string, columnsToSelect: Record, ): void { // Add ORDER BY columns with underscore alias for DISTINCT compatibility // This must be called AFTER setFindOptions because setFindOptions clears addSelect // We need to add columns that are in orderBy but NOT in the selected columns for (const orderByKey of Object.keys(parsedOrderBy)) { const parts = orderByKey.split('.'); if (parts.length === 2) { const [alias, column] = parts; // For relation columns: always add (they're never in columnsToSelect) // For main entity columns: only add if NOT already in columnsToSelect const isMainEntity = alias === objectNameSingular; const isAlreadySelected = isMainEntity && columnsToSelect[column]; if (!isAlreadySelected) { queryBuilder.addSelect( `"${alias}"."${column}"`, `${alias}_${column}`, ); } } } } public getOrderByRawSQL( orderBy: ObjectRecordOrderBy | OrderByWithGroupBy, objectNameSingular: string, isForwardPagination = true, ): { orderByRawSQL: string; relationJoins: RelationJoinInfo[] } { const parseResult = this.orderFieldParser.parse( orderBy as ObjectRecordOrderBy, objectNameSingular, isForwardPagination, ); const orderByRawSQLClauseArray = Object.entries(parseResult.orderBy).map( ([orderByField, orderByCondition]) => { const nullsCondition = isDefined(orderByCondition.nulls) ? ` ${orderByCondition.nulls}` : ''; // Convert "alias.column" to quoted SQL identifier "alias"."column" const parts = orderByField.split('.'); const quotedColumn = parts.length === 2 ? `"${parts[0]}"."${parts[1]}"` : `"${orderByField}"`; // Build column expression with optional ::text cast and LOWER() let columnExpr = quotedColumn; if (orderByCondition.castToText) { columnExpr = `${columnExpr}::text`; } if (orderByCondition.useLower) { columnExpr = `LOWER(${columnExpr})`; } return `${columnExpr} ${orderByCondition.order}${nullsCondition}`; }, ); const orderByRawSQLString = orderByRawSQLClauseArray.join(', '); const orderByRawSQL = isNonEmptyString(orderByRawSQLString) ? `ORDER BY ${orderByRawSQLString}` : ''; return { orderByRawSQL, relationJoins: parseResult.relationJoins }; } public applyGroupByOrderToBuilder( // oxlint-disable-next-line typescript/no-explicit-any queryBuilder: WorkspaceSelectQueryBuilder, orderBy: ObjectRecordOrderBy | OrderByWithGroupBy, groupByFields: GroupByField[], // oxlint-disable-next-line typescript/no-explicit-any ): WorkspaceSelectQueryBuilder { const parsedOrderBys = this.orderGroupByParser.parse({ orderBy, groupByFields, }); parsedOrderBys.forEach((orderByField, index) => { Object.entries(orderByField).forEach(([expression, direction]) => { if (index === 0) { queryBuilder.orderBy(expression, direction.order, direction.nulls); } else { queryBuilder.addOrderBy(expression, direction.order, direction.nulls); } }); }); return queryBuilder; } public parseSelectedFields( // oxlint-disable-next-line typescript/no-explicit-any graphqlSelectedFields: Partial>, ): GraphqlQuerySelectedFieldsResult { const selectedFieldsParser = new GraphqlQuerySelectedFieldsParser( this.flatObjectMetadataMaps, this.flatFieldMetadataMaps, ); return selectedFieldsParser.parse( graphqlSelectedFields, this.flatObjectMetadata, ); } }