Jules
Initial clean CRM deployment with prebuilt assets
d9494a5
Raw
History Blame Contribute Delete
9.87 kB
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<FlatObjectMetadata>;
private flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
private filterConditionParser: GraphqlQueryFilterConditionParser;
private orderFieldParser: GraphqlQueryOrderFieldParser;
private orderGroupByParser: GraphqlQueryOrderGroupByParser;
constructor(
flatObjectMetadata: FlatObjectMetadata,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) {
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<any>,
objectNameSingular: string,
recordFilter: Partial<ObjectRecordFilter>,
// oxlint-disable-next-line typescript/no-explicit-any
): WorkspaceSelectQueryBuilder<any> {
return this.filterConditionParser.parse(
queryBuilder,
objectNameSingular,
recordFilter,
);
}
public applyDeletedAtToBuilder(
// oxlint-disable-next-line typescript/no-explicit-any
queryBuilder: WorkspaceSelectQueryBuilder<any>,
recordFilter: Partial<ObjectRecordFilter>,
// oxlint-disable-next-line typescript/no-explicit-any
): WorkspaceSelectQueryBuilder<any> {
if (this.checkForDeletedAtFilter(recordFilter)) {
queryBuilder.withDeleted();
}
return queryBuilder;
}
private checkForDeletedAtFilter = (
filter: FindOptionsWhere<ObjectLiteral> | FindOptionsWhere<ObjectLiteral>[],
): 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<ObjectLiteral>)
) {
return true;
}
}
return false;
};
public applyOrderToBuilder(
// oxlint-disable-next-line typescript/no-explicit-any
queryBuilder: WorkspaceSelectQueryBuilder<any>,
orderBy: ObjectRecordOrderBy | OrderByWithGroupBy,
objectNameSingular: string,
isForwardPagination = true,
): Record<string, OrderByClause> {
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<any>,
parsedOrderBy: Record<string, OrderByClause>,
objectNameSingular: string,
columnsToSelect: Record<string, boolean>,
): 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<any>,
orderBy: ObjectRecordOrderBy | OrderByWithGroupBy,
groupByFields: GroupByField[],
// oxlint-disable-next-line typescript/no-explicit-any
): WorkspaceSelectQueryBuilder<any> {
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<Record<string, any>>,
): GraphqlQuerySelectedFieldsResult {
const selectedFieldsParser = new GraphqlQuerySelectedFieldsParser(
this.flatObjectMetadataMaps,
this.flatFieldMetadataMaps,
);
return selectedFieldsParser.parse(
graphqlSelectedFields,
this.flatObjectMetadata,
);
}
}