diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx index 086fe6b33..48e83e11c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx @@ -6,6 +6,7 @@ import { RecordChip } from '@/object-record/components/RecordChip'; import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; +import { isNull } from '@sniptt/guards'; export const RelationFromManyFieldDisplay = () => { const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay(); @@ -47,37 +48,43 @@ export const RelationFromManyFieldDisplay = () => { return ( - {fieldValue.map((record) => ( - - ))} + {fieldValue + .filter((record) => !isNull(record[relationFieldName])) + .map((record) => ( + + ))} ); } else if (isRelationFromActivityTargets) { return ( - {activityTargetObjectRecords.map((record) => ( - - ))} + {activityTargetObjectRecords + .filter((record) => !isNull(record.targetObject)) + .map((record) => ( + + ))} ); } else { return ( - {fieldValue.map((record) => ( - - ))} + {fieldValue + .filter((record) => !isNull(record)) + .map((record) => ( + + ))} ); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts index d27996c02..093364db8 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts @@ -1,4 +1,9 @@ -import { FindOptionsWhere, ObjectLiteral } from 'typeorm'; +import { + Brackets, + NotBrackets, + SelectQueryBuilder, + WhereExpressionBuilder, +} from 'typeorm'; import { RecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; @@ -8,106 +13,138 @@ import { GraphqlQueryFilterFieldParser } from './graphql-query-filter-field.pars export class GraphqlQueryFilterConditionParser { private fieldMetadataMap: FieldMetadataMap; - private fieldConditionParser: GraphqlQueryFilterFieldParser; + private queryFilterFieldParser: GraphqlQueryFilterFieldParser; constructor(fieldMetadataMap: FieldMetadataMap) { this.fieldMetadataMap = fieldMetadataMap; - this.fieldConditionParser = new GraphqlQueryFilterFieldParser( + this.queryFilterFieldParser = new GraphqlQueryFilterFieldParser( this.fieldMetadataMap, ); } public parse( - conditions: RecordFilter, - isNegated = false, - ): FindOptionsWhere | FindOptionsWhere[] { - if (Array.isArray(conditions)) { - return this.parseAndCondition(conditions, isNegated); + queryBuilder: SelectQueryBuilder, + objectNameSingular: string, + filter: RecordFilter, + ): SelectQueryBuilder { + if (!filter || Object.keys(filter).length === 0) { + return queryBuilder; } - const result: FindOptionsWhere = {}; + return queryBuilder.where( + new Brackets((qb) => { + Object.entries(filter).forEach(([key, value], index) => { + this.parseKeyFilter(qb, objectNameSingular, key, value, index === 0); + }); + }), + ); + } - for (const [key, value] of Object.entries(conditions)) { - switch (key) { - case 'and': { - const andConditions = this.parseAndCondition(value, isNegated); + private parseKeyFilter( + queryBuilder: WhereExpressionBuilder, + objectNameSingular: string, + key: string, + value: any, + isFirst = false, + ): void { + switch (key) { + case 'and': { + const andWhereCondition = new Brackets((qb) => { + value.forEach((filter: RecordFilter, index: number) => { + const whereCondition = new Brackets((qb2) => { + Object.entries(filter).forEach( + ([subFilterkey, subFilterValue], index) => { + this.parseKeyFilter( + qb2, + objectNameSingular, + subFilterkey, + subFilterValue, + index === 0, + ); + }, + ); + }); - return andConditions.map((condition) => ({ - ...result, - ...condition, - })); + if (index === 0) { + qb.where(whereCondition); + } else { + qb.andWhere(whereCondition); + } + }); + }); + + if (isFirst) { + queryBuilder.where(andWhereCondition); + } else { + queryBuilder.andWhere(andWhereCondition); } - case 'or': { - const orConditions = this.parseOrCondition(value, isNegated); - - return orConditions.map((condition) => ({ ...result, ...condition })); - } - case 'not': - Object.assign(result, this.parse(value, !isNegated)); - break; - default: - Object.assign( - result, - this.fieldConditionParser.parse(key, value, isNegated), - ); + break; } + case 'or': { + const orWhereCondition = new Brackets((qb) => { + value.forEach((filter: RecordFilter, index: number) => { + const whereCondition = new Brackets((qb2) => { + Object.entries(filter).forEach( + ([subFilterkey, subFilterValue], index) => { + this.parseKeyFilter( + qb2, + objectNameSingular, + subFilterkey, + subFilterValue, + index === 0, + ); + }, + ); + }); + + if (index === 0) { + qb.where(whereCondition); + } else { + qb.orWhere(whereCondition); + } + }); + }); + + if (isFirst) { + queryBuilder.where(orWhereCondition); + } else { + queryBuilder.andWhere(orWhereCondition); + } + + break; + } + case 'not': { + const notWhereCondition = new NotBrackets((qb) => { + Object.entries(value).forEach( + ([subFilterkey, subFilterValue], index) => { + this.parseKeyFilter( + qb, + objectNameSingular, + subFilterkey, + subFilterValue, + index === 0, + ); + }, + ); + }); + + if (isFirst) { + queryBuilder.where(notWhereCondition); + } else { + queryBuilder.andWhere(notWhereCondition); + } + + break; + } + default: + this.queryFilterFieldParser.parse( + queryBuilder, + objectNameSingular, + key, + value, + isFirst, + ); + break; } - - return result; - } - - private parseAndCondition( - conditions: RecordFilter[], - isNegated: boolean, - ): FindOptionsWhere[] { - const parsedConditions = conditions.map((condition) => - this.parse(condition, isNegated), - ); - - return this.combineConditions(parsedConditions, isNegated ? 'or' : 'and'); - } - - private parseOrCondition( - conditions: RecordFilter[], - isNegated: boolean, - ): FindOptionsWhere[] { - const parsedConditions = conditions.map((condition) => - this.parse(condition, isNegated), - ); - - return this.combineConditions(parsedConditions, isNegated ? 'and' : 'or'); - } - - private combineConditions( - conditions: ( - | FindOptionsWhere - | FindOptionsWhere[] - )[], - combineType: 'and' | 'or', - ): FindOptionsWhere[] { - if (combineType === 'and') { - return conditions.reduce[]>( - (acc, condition) => { - if (Array.isArray(condition)) { - return acc.flatMap((accCondition) => - condition.map((subCondition) => ({ - ...accCondition, - ...subCondition, - })), - ); - } - - return acc.map((accCondition) => ({ - ...accCondition, - ...condition, - })); - }, - [{}], - ); - } - - return conditions.flatMap((condition) => - Array.isArray(condition) ? condition : [condition], - ); } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts index 9df7ab803..9918a88af 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts @@ -1,64 +1,153 @@ -import { FindOptionsWhere, Not, ObjectLiteral } from 'typeorm'; +import { ObjectLiteral, WhereExpressionBuilder } from 'typeorm'; -import { RecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { FieldMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; import { capitalize } from 'src/utils/capitalize'; -import { isPlainObject } from 'src/utils/is-plain-object'; -import { GraphqlQueryFilterConditionParser } from './graphql-query-filter-condition.parser'; -import { GraphqlQueryFilterOperatorParser } from './graphql-query-filter-operator.parser'; +type WhereConditionParts = { + sql: string; + params: ObjectLiteral; +}; export class GraphqlQueryFilterFieldParser { private fieldMetadataMap: FieldMetadataMap; - private operatorParser: GraphqlQueryFilterOperatorParser; constructor(fieldMetadataMap: FieldMetadataMap) { this.fieldMetadataMap = fieldMetadataMap; - this.operatorParser = new GraphqlQueryFilterOperatorParser(); } public parse( + queryBuilder: WhereExpressionBuilder, + objectNameSingular: string, key: string, - value: any, - isNegated: boolean, - ): FindOptionsWhere { - const fieldMetadata = this.fieldMetadataMap[key]; + filterValue: any, + isFirst = false, + ): void { + const fieldMetadata = this.fieldMetadataMap[`${key}`]; if (!fieldMetadata) { - return { - [key]: (value: RecordFilter, isNegated: boolean) => { - const conditionParser = new GraphqlQueryFilterConditionParser( - this.fieldMetadataMap, - ); - - return conditionParser.parse(value, isNegated); - }, - }; + throw new Error(`Field metadata not found for field: ${key}`); } if (isCompositeFieldMetadataType(fieldMetadata.type)) { - return this.parseCompositeFieldForFilter(fieldMetadata, value, isNegated); + return this.parseCompositeFieldForFilter( + queryBuilder, + fieldMetadata, + objectNameSingular, + filterValue, + isFirst, + ); } + const [[operator, value]] = Object.entries(filterValue); - if (isPlainObject(value)) { - const parsedValue = this.operatorParser.parseOperator(value, isNegated); + const { sql, params } = this.computeWhereConditionParts( + fieldMetadata, + operator, + objectNameSingular, + key, + value, + ); - return { [key]: parsedValue }; + if (isFirst) { + queryBuilder.where(sql, params); + } else { + queryBuilder.andWhere(sql, params); } + } - return { [key]: isNegated ? Not(value) : value }; + private computeWhereConditionParts( + fieldMetadata: FieldMetadataInterface, + operator: string, + objectNameSingular: string, + key: string, + value: any, + ): WhereConditionParts { + const uuid = Math.random().toString(36).slice(2, 7); + + switch (operator) { + case 'eq': + return { + sql: `${objectNameSingular}.${key} = :${key}${uuid}`, + params: { [`${key}${uuid}`]: value }, + }; + case 'neq': + return { + sql: `${objectNameSingular}.${key} != :${key}${uuid}`, + params: { [`${key}${uuid}`]: value }, + }; + case 'gt': + return { + sql: `${objectNameSingular}.${key} > :${key}${uuid}`, + params: { [`${key}${uuid}`]: value }, + }; + case 'gte': + return { + sql: `${objectNameSingular}.${key} >= :${key}${uuid}`, + params: { [`${key}${uuid}`]: value }, + }; + case 'lt': + return { + sql: `${objectNameSingular}.${key} < :${key}${uuid}`, + params: { [`${key}${uuid}`]: value }, + }; + case 'lte': + return { + sql: `${objectNameSingular}.${key} <= :${key}${uuid}`, + params: { [`${key}${uuid}`]: value }, + }; + case 'in': + return { + sql: `${objectNameSingular}.${key} IN (:...${key}${uuid})`, + params: { [`${key}${uuid}`]: value }, + }; + case 'is': + return { + sql: `${objectNameSingular}.${key} IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}`, + params: {}, + }; + case 'like': + return { + sql: `${objectNameSingular}.${key} LIKE :${key}${uuid}`, + params: { [`${key}${uuid}`]: `${value}` }, + }; + case 'ilike': + return { + sql: `${objectNameSingular}.${key} ILIKE :${key}${uuid}`, + params: { [`${key}${uuid}`]: `${value}` }, + }; + case 'startsWith': + return { + sql: `${objectNameSingular}.${key} LIKE :${key}${uuid}`, + params: { [`${key}${uuid}`]: `${value}` }, + }; + case 'endsWith': + return { + sql: `${objectNameSingular}.${key} LIKE :${key}${uuid}`, + params: { [`${key}${uuid}`]: `${value}` }, + }; + default: + throw new GraphqlQueryRunnerException( + `Operator "${operator}" is not supported`, + GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR, + ); + } } private parseCompositeFieldForFilter( + queryBuilder: WhereExpressionBuilder, fieldMetadata: FieldMetadataInterface, + objectNameSingular: string, fieldValue: any, - isNegated: boolean, - ): FindOptionsWhere { + isFirst = false, + ): void { const compositeType = compositeTypeDefinitions.get( fieldMetadata.type as CompositeFieldMetadataType, ); @@ -69,34 +158,36 @@ export class GraphqlQueryFilterFieldParser { ); } - return Object.entries(fieldValue).reduce( - (result, [subFieldKey, subFieldValue]) => { - const subFieldMetadata = compositeType.properties.find( - (property) => property.name === subFieldKey, + Object.entries(fieldValue).map(([subFieldKey, subFieldFilter], index) => { + const subFieldMetadata = compositeType.properties.find( + (property) => property.name === subFieldKey, + ); + + if (!subFieldMetadata) { + throw new Error( + `Sub field metadata not found for composite type: ${fieldMetadata.type}`, ); + } - if (!subFieldMetadata) { - throw new Error( - `Sub field metadata not found for composite type: ${fieldMetadata.type}`, - ); - } + const fullFieldName = `${fieldMetadata.name}${capitalize(subFieldKey)}`; - const fullFieldName = `${fieldMetadata.name}${capitalize(subFieldKey)}`; + const [[operator, value]] = Object.entries( + subFieldFilter as Record, + ); - if (isPlainObject(subFieldValue)) { - result[fullFieldName] = this.operatorParser.parseOperator( - subFieldValue, - isNegated, - ); - } else { - result[fullFieldName] = isNegated - ? Not(subFieldValue) - : subFieldValue; - } + const { sql, params } = this.computeWhereConditionParts( + fieldMetadata, + operator, + objectNameSingular, + fullFieldName, + value, + ); - return result; - }, - {} as FindOptionsWhere, - ); + if (isFirst && index === 0) { + queryBuilder.where(sql, params); + } + + queryBuilder.andWhere(sql, params); + }); } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-operator.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-operator.parser.ts deleted file mode 100644 index 8deb04664..000000000 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-operator.parser.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - FindOperator, - ILike, - In, - IsNull, - LessThan, - LessThanOrEqual, - Like, - MoreThan, - MoreThanOrEqual, - Not, -} from 'typeorm'; - -import { - GraphqlQueryRunnerException, - GraphqlQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; - -export class GraphqlQueryFilterOperatorParser { - private operatorMap: { [key: string]: (value: any) => FindOperator }; - - constructor() { - this.operatorMap = { - eq: (value: any) => value, - neq: (value: any) => Not(value), - gt: (value: any) => MoreThan(value), - gte: (value: any) => MoreThanOrEqual(value), - lt: (value: any) => LessThan(value), - lte: (value: any) => LessThanOrEqual(value), - in: (value: any) => In(value), - is: (value: any) => { - if (value === 'NULL') { - return IsNull(); - } else if (value === 'NOT_NULL') { - return Not(IsNull()); - } else { - return value; - } - }, - like: (value: string) => Like(`%${value}%`), - ilike: (value: string) => ILike(`%${value}%`), - startsWith: (value: string) => ILike(`${value}%`), - endsWith: (value: string) => ILike(`%${value}`), - }; - } - - public parseOperator( - operatorObj: Record, - isNegated: boolean, - ): FindOperator { - const [[operator, value]] = Object.entries(operatorObj); - - if (operator in this.operatorMap) { - const operatorFunction = this.operatorMap[operator]; - - return isNegated ? Not(operatorFunction(value)) : operatorFunction(value); - } - - throw new GraphqlQueryRunnerException( - `Operator "${operator}" is not supported`, - GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR, - ); - } -} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts index 914c2f4d4..aaa242d80 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts @@ -1,5 +1,3 @@ -import { FindOptionsOrderValue } from 'typeorm'; - import { OrderByDirection, RecordOrderBy, @@ -24,8 +22,9 @@ export class GraphqlQueryOrderFieldParser { parse( orderBy: RecordOrderBy, + objectNameSingular: string, isForwardPagination = true, - ): Record { + ): Record { return orderBy.reduce( (acc, item) => { Object.entries(item).forEach(([key, value]) => { @@ -42,29 +41,29 @@ export class GraphqlQueryOrderFieldParser { const compositeOrder = this.parseCompositeFieldForOrder( fieldMetadata, value, + objectNameSingular, isForwardPagination, ); Object.assign(acc, compositeOrder); } else { - acc[key] = this.convertOrderByToFindOptionsOrder( - value, - isForwardPagination, - ); + acc[`"${objectNameSingular}"."${key}"`] = + this.convertOrderByToFindOptionsOrder(value, isForwardPagination); } }); return acc; }, - {} as Record, + {} as Record, ); } private parseCompositeFieldForOrder( fieldMetadata: FieldMetadataInterface, value: any, + objectNameSingular: string, isForwardPagination = true, - ): Record { + ): Record { const compositeType = compositeTypeDefinitions.get( fieldMetadata.type as CompositeFieldMetadataType, ); @@ -87,7 +86,7 @@ export class GraphqlQueryOrderFieldParser { ); } - const fullFieldName = `${fieldMetadata.name}${capitalize(subFieldKey)}`; + const fullFieldName = `"${objectNameSingular}"."${fieldMetadata.name}${capitalize(subFieldKey)}"`; if (!this.isOrderByDirection(subFieldValue)) { throw new Error( @@ -101,35 +100,23 @@ export class GraphqlQueryOrderFieldParser { return acc; }, - {} as Record, + {} as Record, ); } private convertOrderByToFindOptionsOrder( direction: OrderByDirection, isForwardPagination = true, - ): FindOptionsOrderValue { + ): string { switch (direction) { case OrderByDirection.AscNullsFirst: - return { - direction: isForwardPagination ? 'ASC' : 'DESC', - nulls: 'FIRST', - }; + return `${isForwardPagination ? 'ASC' : 'DESC'} NULLS FIRST`; case OrderByDirection.AscNullsLast: - return { - direction: isForwardPagination ? 'ASC' : 'DESC', - nulls: 'LAST', - }; + return `${isForwardPagination ? 'ASC' : 'DESC'} NULLS LAST`; case OrderByDirection.DescNullsFirst: - return { - direction: isForwardPagination ? 'DESC' : 'ASC', - nulls: 'FIRST', - }; + return `${isForwardPagination ? 'DESC' : 'ASC'} NULLS FIRST`; case OrderByDirection.DescNullsLast: - return { - direction: isForwardPagination ? 'DESC' : 'ASC', - nulls: 'LAST', - }; + return `${isForwardPagination ? 'DESC' : 'ASC'} NULLS LAST`; default: throw new GraphqlQueryRunnerException( `Invalid direction: ${direction}`, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index fc1be7e98..157187fd0 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -1,7 +1,8 @@ import { - FindOptionsOrderValue, FindOptionsWhere, ObjectLiteral, + OrderByCondition, + SelectQueryBuilder, } from 'typeorm'; import { @@ -10,8 +11,8 @@ import { } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; -import { GraphqlQueryFilterConditionParser as GraphqlQueryFilterParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser'; -import { GraphqlQueryOrderFieldParser as GraphqlQueryOrderParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; +import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser'; +import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; import { GraphqlQuerySelectedFieldsParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; import { FieldMetadataMap, @@ -21,6 +22,8 @@ import { export class GraphqlQueryParser { private fieldMetadataMap: FieldMetadataMap; private objectMetadataMap: ObjectMetadataMap; + private filterConditionParser: GraphqlQueryFilterConditionParser; + private orderFieldParser: GraphqlQueryOrderFieldParser; constructor( fieldMetadataMap: FieldMetadataMap, @@ -28,33 +31,44 @@ export class GraphqlQueryParser { ) { this.objectMetadataMap = objectMetadataMap; this.fieldMetadataMap = fieldMetadataMap; - } - - parseFilter(recordFilter: RecordFilter): { - parsedFilters: - | FindOptionsWhere - | FindOptionsWhere[]; - withDeleted: boolean; - } { - const graphqlQueryFilterParser = new GraphqlQueryFilterParser( + this.filterConditionParser = new GraphqlQueryFilterConditionParser( this.fieldMetadataMap, ); + this.orderFieldParser = new GraphqlQueryOrderFieldParser( + this.fieldMetadataMap, + ); + } - const parsedFilter = graphqlQueryFilterParser.parse(recordFilter); + applyFilterToBuilder( + queryBuilder: SelectQueryBuilder, + objectNameSingular: string, + recordFilter: RecordFilter, + ): SelectQueryBuilder { + return this.filterConditionParser.parse( + queryBuilder, + objectNameSingular, + recordFilter, + ); + } - const hasDeletedAtFilter = this.checkForDeletedAtFilter(parsedFilter); + applyDeletedAtToBuilder( + queryBuilder: SelectQueryBuilder, + recordFilter: RecordFilter, + ): SelectQueryBuilder { + if (this.checkForDeletedAtFilter(recordFilter)) { + queryBuilder.withDeleted(); + } - return { - parsedFilters: parsedFilter, - withDeleted: hasDeletedAtFilter, - }; + return queryBuilder; } private checkForDeletedAtFilter( filter: FindOptionsWhere | FindOptionsWhere[], ): boolean { if (Array.isArray(filter)) { - return filter.some(this.checkForDeletedAtFilter); + return filter.some((subFilter) => + this.checkForDeletedAtFilter(subFilter), + ); } for (const [key, value] of Object.entries(filter)) { @@ -74,15 +88,19 @@ export class GraphqlQueryParser { return false; } - parseOrder( + applyOrderToBuilder( + queryBuilder: SelectQueryBuilder, orderBy: RecordOrderBy, + objectNameSingular: string, isForwardPagination = true, - ): Record { - const graphqlQueryOrderParser = new GraphqlQueryOrderParser( - this.fieldMetadataMap, + ): SelectQueryBuilder { + const parsedOrderBys = this.orderFieldParser.parse( + orderBy, + objectNameSingular, + isForwardPagination, ); - return graphqlQueryOrderParser.parse(orderBy, isForwardPagination); + return queryBuilder.orderBy(parsedOrderBys as OrderByCondition); } parseSelectedFields( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts index 3284faaf3..f19c7cf06 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts @@ -77,7 +77,7 @@ export class ProcessNestedRelationsHelper { if (Object.keys(nestedRelations).length > 0) { await this.processNestedRelations( objectMetadataMap, - objectMetadataMap[relationName], + objectMetadataMap[referenceObjectMetadataName], relationResults as ObjectRecord[], nestedRelations as Record>, limit, @@ -126,6 +126,9 @@ export class ProcessNestedRelationsHelper { const relationResults = await relationRepository.find(relationFindOptions); parentObjectRecords.forEach((item) => { + if (relationResults.length === 0) { + (item as any)[`${relationName}Id`] = null; + } (item as any)[relationName] = relationResults.filter( (rel) => rel.id === item[`${relationName}Id`], )[0]; @@ -134,7 +137,7 @@ export class ProcessNestedRelationsHelper { if (Object.keys(nestedRelations).length > 0) { await this.processNestedRelations( objectMetadataMap, - objectMetadataMap[relationName], + objectMetadataMap[referenceObjectMetadataName], relationResults as ObjectRecord[], nestedRelations as Record>, limit, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts index 88d140932..b9a81ef24 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts @@ -1,6 +1,7 @@ -import { FindOptionsOrderValue } from 'typeorm'; - -import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { + Record as IRecord, + RecordOrderBy, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; @@ -31,7 +32,7 @@ export class ObjectRecordsToGraphqlConnectionMapper { objectName: string, take: number, totalCount: number, - order: Record | undefined, + order: RecordOrderBy | undefined, hasNextPage: boolean, hasPreviousPage: boolean, depth = 0, @@ -65,7 +66,7 @@ export class ObjectRecordsToGraphqlConnectionMapper { objectName: string, take: number, totalCount: number, - order: Record | undefined = {}, + order?: RecordOrderBy, depth = 0, ): T { if (depth >= CONNECTION_MAX_DEPTH) { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts index 5608d5ed5..396b5ffdd 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts @@ -1,9 +1,9 @@ import { isDefined } from 'class-validator'; import graphqlFields from 'graphql-fields'; -import { FindManyOptions, ObjectLiteral } from 'typeorm'; import { Record as IRecord, + OrderByDirection, RecordFilter, RecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; @@ -19,14 +19,15 @@ import { import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper'; -import { applyRangeFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/apply-range-filter.util'; +import { computeCursorArgFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter'; import { decodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util'; import { - generateObjectMetadataMap, ObjectMetadataMapItem, + generateObjectMetadataMap, } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; export class GraphqlQueryFindManyResolverService { private twentyORMGlobalManager: TwentyORMGlobalManager; @@ -56,9 +57,19 @@ export class GraphqlQueryFindManyResolverService { const repository = dataSource.getRepository( objectMetadataItem.nameSingular, ); + + const queryBuilder = repository.createQueryBuilder( + objectMetadataItem.nameSingular, + ); + + const countQueryBuilder = repository.createQueryBuilder( + objectMetadataItem.nameSingular, + ); + const objectMetadataMap = generateObjectMetadataMap( objectMetadataCollection, ); + const objectMetadata = getObjectMetadataOrThrow( objectMetadataMap, objectMetadataItem.nameSingular, @@ -68,45 +79,82 @@ export class GraphqlQueryFindManyResolverService { objectMetadataMap, ); + const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder( + countQueryBuilder, + objectMetadataItem.nameSingular, + args.filter ?? ({} as Filter), + ); + const selectedFields = graphqlFields(info); - const { select, relations } = graphqlQueryParser.parseSelectedFields( + const { relations } = graphqlQueryParser.parseSelectedFields( objectMetadataItem, selectedFields, ); const isForwardPagination = !isDefined(args.before); - const order = graphqlQueryParser.parseOrder( - args.orderBy ?? [], - isForwardPagination, - ); - const { parsedFilters: where, withDeleted } = - graphqlQueryParser.parseFilter(args.filter ?? ({} as Filter)); - const cursor = this.getCursor(args); const limit = args.first ?? args.last ?? QUERY_MAX_RECORDS; - this.addOrderByColumnsToSelect(order, select); - this.addForeingKeyColumnsToSelect(relations, select, objectMetadata); - - const findOptions: FindManyOptions = { - where, - order, - select, - take: limit + 1, - withDeleted, - }; + const withDeletedCountQueryBuilder = + graphqlQueryParser.applyDeletedAtToBuilder( + withFilterCountQueryBuilder, + args.filter ?? ({} as Filter), + ); const totalCount = isDefined(selectedFields.totalCount) - ? await repository.count({ where, withDeleted }) + ? await withDeletedCountQueryBuilder.getCount() : 0; + const cursor = this.getCursor(args); + + let appliedFilters = args.filter ?? ({} as Filter); + + const orderByWithIdCondition = [ + ...(args.orderBy ?? []), + { id: OrderByDirection.AscNullsFirst }, + ] as OrderBy; + if (cursor) { - applyRangeFilter(where, cursor, isForwardPagination); + const cursorArgFilter = computeCursorArgFilter( + cursor, + orderByWithIdCondition, + isForwardPagination, + ); + + appliedFilters = (args.filter + ? { + and: [args.filter, { or: cursorArgFilter }], + } + : { or: cursorArgFilter }) as unknown as Filter; } - const objectRecords = (await repository.find( - findOptions, - )) as ObjectRecord[]; + const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( + queryBuilder, + objectMetadataItem.nameSingular, + appliedFilters, + ); + + const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder( + withFilterQueryBuilder, + orderByWithIdCondition, + objectMetadataItem.nameSingular, + isForwardPagination, + ); + + const withDeletedQueryBuilder = graphqlQueryParser.applyDeletedAtToBuilder( + withOrderByQueryBuilder, + args.filter ?? ({} as Filter), + ); + + const nonFormattedObjectRecords = await withDeletedQueryBuilder + .take(limit + 1) + .getMany(); + + const objectRecords = formatResult( + nonFormattedObjectRecords, + objectMetadata, + objectMetadataMap, + ); const { hasNextPage, hasPreviousPage } = this.getPaginationInfo( objectRecords, @@ -142,7 +190,7 @@ export class GraphqlQueryFindManyResolverService { objectMetadataItem.nameSingular, limit, totalCount, - order, + orderByWithIdCondition, hasNextPage, hasPreviousPage, ); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts index c1609e0c0..b9e86e420 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts @@ -18,6 +18,7 @@ import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/g import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util'; import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; export class GraphqlQueryFindOneResolverService { private twentyORMGlobalManager: TwentyORMGlobalManager; @@ -35,12 +36,17 @@ export class GraphqlQueryFindOneResolverService { ): Promise { const { authContext, objectMetadataItem, info, objectMetadataCollection } = options; + const dataSource = await this.twentyORMGlobalManager.getDataSourceForWorkspace( authContext.workspace.id, ); - const repository = await dataSource.getRepository( + const repository = dataSource.getRepository( + objectMetadataItem.nameSingular, + ); + + const queryBuilder = repository.createQueryBuilder( objectMetadataItem.nameSingular, ); @@ -52,6 +58,7 @@ export class GraphqlQueryFindOneResolverService { objectMetadataMap, objectMetadataItem.nameSingular, ); + const graphqlQueryParser = new GraphqlQueryParser( objectMetadata.fields, objectMetadataMap, @@ -59,18 +66,29 @@ export class GraphqlQueryFindOneResolverService { const selectedFields = graphqlFields(info); - const { select, relations } = graphqlQueryParser.parseSelectedFields( + const { relations } = graphqlQueryParser.parseSelectedFields( objectMetadataItem, selectedFields, ); - const { parsedFilters: where, withDeleted } = - graphqlQueryParser.parseFilter(args.filter ?? ({} as Filter)); - const objectRecord = (await repository.findOne({ - where, - select, - withDeleted, - })) as ObjectRecord; + const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( + queryBuilder, + objectMetadataItem.nameSingular, + args.filter ?? ({} as Filter), + ); + + const withDeletedQueryBuilder = graphqlQueryParser.applyDeletedAtToBuilder( + withFilterQueryBuilder, + args.filter ?? ({} as Filter), + ); + + const nonFormattedObjectRecord = await withDeletedQueryBuilder.getOne(); + + const objectRecord = formatResult( + nonFormattedObjectRecord, + objectMetadata, + objectMetadataMap, + ); const limit = QUERY_MAX_RECORDS; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/apply-range-filter.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/apply-range-filter.util.ts deleted file mode 100644 index 3a536c68c..000000000 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/apply-range-filter.util.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { FindOptionsWhere, LessThan, MoreThan, ObjectLiteral } from 'typeorm'; - -export const applyRangeFilter = ( - where: FindOptionsWhere, - cursor: Record, - isForwardPagination = true, -): FindOptionsWhere => { - Object.entries(cursor ?? {}).forEach(([key, value]) => { - if (key === 'id') { - return; - } - where[key] = isForwardPagination ? MoreThan(value) : LessThan(value); - }); - - return where; -}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter.ts new file mode 100644 index 000000000..ed55af29e --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter.ts @@ -0,0 +1,66 @@ +import { + OrderByDirection, + RecordFilter, + RecordOrderBy, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; + +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; + +export const computeCursorArgFilter = ( + cursor: Record, + orderBy: RecordOrderBy, + isForwardPagination = true, +): RecordFilter[] => { + const cursorKeys = Object.keys(cursor ?? {}); + const cursorValues = Object.values(cursor ?? {}); + + if (cursorKeys.length === 0) { + return []; + } + + return Object.entries(cursor ?? {}).map(([key, value], index) => { + let whereCondition = {}; + + for ( + let subConditionIndex = 0; + subConditionIndex < index; + subConditionIndex++ + ) { + whereCondition = { + ...whereCondition, + [cursorKeys[subConditionIndex]]: { + eq: cursorValues[subConditionIndex], + }, + }; + } + + const keyOrderBy = orderBy.find((order) => key in order); + + if (!keyOrderBy) { + throw new GraphqlQueryRunnerException( + 'Invalid cursor', + GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, + ); + } + + const isAscending = + keyOrderBy[key] === OrderByDirection.AscNullsFirst || + keyOrderBy[key] === OrderByDirection.AscNullsLast; + + const operator = isAscending + ? isForwardPagination + ? 'gt' + : 'lt' + : isForwardPagination + ? 'lt' + : 'gt'; + + return { + ...whereCondition, + ...{ [key]: { [operator]: value } }, + } as RecordFilter; + }); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts index 5a556850f..bf8eb52d0 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts @@ -1,6 +1,7 @@ -import { FindOptionsOrderValue } from 'typeorm'; - -import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { + Record as IRecord, + RecordOrderBy, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; import { GraphqlQueryRunnerException, @@ -24,11 +25,15 @@ export const decodeCursor = (cursor: string): CursorData => { export const encodeCursor = ( objectRecord: ObjectRecord, - order: Record | undefined, + order: RecordOrderBy | undefined, ): string => { const orderByValues: Record = {}; - Object.keys(order ?? {}).forEach((key) => { + const orderBy = order?.reduce((acc, orderBy) => ({ ...acc, ...orderBy }), {}); + + const orderByKeys = Object.keys(orderBy ?? {}); + + orderByKeys?.forEach((key) => { orderByValues[key] = objectRecord[key]; }); diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts index a264b44bf..ea3c3f9e8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts @@ -1,5 +1,3 @@ -import { isPlainObject } from '@nestjs/common/utils/shared.utils'; - import { DeepPartial, DeleteResult, @@ -24,14 +22,10 @@ import { UpsertOptions } from 'typeorm/repository/UpsertOptions'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; -import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; -import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; -import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; -import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; import { WorkspaceEntitiesStorage } from 'src/engine/twenty-orm/storage/workspace-entities.storage'; -import { computeRelationType } from 'src/engine/twenty-orm/utils/compute-relation-type.util'; -import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; +import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; export class WorkspaceRepository< Entity extends ObjectLiteral, @@ -650,18 +644,6 @@ export class WorkspaceRepository< return objectMetadata; } - private async getCompositeFieldMetadataCollection( - objectMetadata: ObjectMetadataMapItem, - ) { - const compositeFieldMetadataCollection = Object.values( - objectMetadata.fields, - ).filter((fieldMetadata) => - isCompositeFieldMetadataType(fieldMetadata.type), - ); - - return compositeFieldMetadataCollection; - } - private async transformOptions< T extends FindManyOptions | FindOneOptions | undefined, >(options: T): Promise { @@ -677,62 +659,9 @@ export class WorkspaceRepository< } private async formatData(data: T): Promise { - if (!data) { - return data; - } - - if (Array.isArray(data)) { - return Promise.all( - data.map((item) => this.formatData(item)), - ) as Promise; - } - const objectMetadata = await this.getObjectMetadataFromTarget(); - const compositeFieldMetadataCollection = - await this.getCompositeFieldMetadataCollection(objectMetadata); - const compositeFieldMetadataMap = new Map( - compositeFieldMetadataCollection.map((fieldMetadata) => [ - fieldMetadata.name, - fieldMetadata, - ]), - ); - const newData: object = {}; - - for (const [key, value] of Object.entries(data)) { - const fieldMetadata = compositeFieldMetadataMap.get(key); - - if (!fieldMetadata) { - if (isPlainObject(value)) { - newData[key] = await this.formatData(value); - } else { - newData[key] = value; - } - continue; - } - - const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); - - if (!compositeType) { - continue; - } - - for (const compositeProperty of compositeType.properties) { - const compositeKey = computeCompositeColumnName( - fieldMetadata.name, - compositeProperty, - ); - const value = data?.[key]?.[compositeProperty.name]; - - if (value === undefined || value === null) { - continue; - } - - newData[compositeKey] = data[key][compositeProperty.name]; - } - } - - return newData as T; + return formatData(data, objectMetadata) as T; } private async formatResult( @@ -741,124 +670,8 @@ export class WorkspaceRepository< ): Promise { objectMetadata ??= await this.getObjectMetadataFromTarget(); - if (!data) { - return data; - } + const objectMetadataMap = this.internalContext.objectMetadataMap; - if (Array.isArray(data)) { - // If the data is an array, map each item in the array, format result is a promise - return Promise.all( - data.map((item) => this.formatResult(item, objectMetadata)), - ) as Promise; - } - - if (!isPlainObject(data)) { - return data; - } - - if (!objectMetadata) { - throw new Error('Object metadata is missing'); - } - - const compositeFieldMetadataCollection = - await this.getCompositeFieldMetadataCollection(objectMetadata); - - const compositeFieldMetadataMap = new Map( - compositeFieldMetadataCollection.flatMap((fieldMetadata) => { - const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); - - if (!compositeType) return []; - - // Map each composite property to a [key, value] pair - return compositeType.properties.map((compositeProperty) => [ - computeCompositeColumnName(fieldMetadata.name, compositeProperty), - { - parentField: fieldMetadata.name, - ...compositeProperty, - }, - ]); - }), - ); - - const relationMetadataMap = new Map( - Object.values(objectMetadata.fields) - .filter(({ type }) => isRelationFieldMetadataType(type)) - .map((fieldMetadata) => [ - fieldMetadata.name, - { - relationMetadata: - fieldMetadata.fromRelationMetadata ?? - fieldMetadata.toRelationMetadata, - relationType: computeRelationType( - fieldMetadata, - fieldMetadata.fromRelationMetadata ?? - (fieldMetadata.toRelationMetadata as RelationMetadataEntity), - ), - }, - ]), - ); - const newData: object = {}; - - for (const [key, value] of Object.entries(data)) { - const compositePropertyArgs = compositeFieldMetadataMap.get(key); - const { relationMetadata, relationType } = - relationMetadataMap.get(key) ?? {}; - - if (!compositePropertyArgs && !relationMetadata) { - if (isPlainObject(value)) { - newData[key] = await this.formatResult(value); - } else { - newData[key] = value; - } - continue; - } - - if (relationMetadata) { - const toObjectMetadata = - this.internalContext.objectMetadataMap[ - relationMetadata.toObjectMetadataId - ]; - - const fromObjectMetadata = - this.internalContext.objectMetadataMap[ - relationMetadata.fromObjectMetadataId - ]; - - if (!toObjectMetadata) { - throw new Error( - `Object metadata for object metadataId "${relationMetadata.toObjectMetadataId}" is missing`, - ); - } - - if (!fromObjectMetadata) { - throw new Error( - `Object metadata for object metadataId "${relationMetadata.fromObjectMetadataId}" is missing`, - ); - } - - newData[key] = await this.formatResult( - value, - - relationType === 'one-to-many' - ? toObjectMetadata - : fromObjectMetadata, - ); - continue; - } - - if (!compositePropertyArgs) { - continue; - } - - const { parentField, ...compositeProperty } = compositePropertyArgs; - - if (!newData[parentField]) { - newData[parentField] = {}; - } - - newData[parentField][compositeProperty.name] = value; - } - - return newData as T; + return formatResult(data, objectMetadata, objectMetadataMap) as T; } } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts new file mode 100644 index 000000000..f6f31f32c --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts @@ -0,0 +1,65 @@ +import { isPlainObject } from '@nestjs/common/utils/shared.utils'; + +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; +import { getCompositeFieldMetadataCollection } from 'src/engine/twenty-orm/utils/get-composite-field-metadata-collection'; + +export function formatData( + data: T, + objectMetadata: ObjectMetadataMapItem, +): T { + if (!data) { + return data; + } + + if (Array.isArray(data)) { + return data.map((item) => formatData(item, objectMetadata)) as T; + } + + const compositeFieldMetadataCollection = + getCompositeFieldMetadataCollection(objectMetadata); + + const compositeFieldMetadataMap = new Map( + compositeFieldMetadataCollection.map((fieldMetadata) => [ + fieldMetadata.name, + fieldMetadata, + ]), + ); + const newData: object = {}; + + for (const [key, value] of Object.entries(data)) { + const fieldMetadata = compositeFieldMetadataMap.get(key); + + if (!fieldMetadata) { + if (isPlainObject(value)) { + newData[key] = formatData(value, objectMetadata); + } else { + newData[key] = value; + } + continue; + } + + const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); + + if (!compositeType) { + continue; + } + + for (const compositeProperty of compositeType.properties) { + const compositeKey = computeCompositeColumnName( + fieldMetadata.name, + compositeProperty, + ); + const value = data?.[key]?.[compositeProperty.name]; + + if (value === undefined || value === null) { + continue; + } + + newData[compositeKey] = data[key][compositeProperty.name]; + } + } + + return newData as T; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts new file mode 100644 index 000000000..81949e074 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts @@ -0,0 +1,131 @@ +import { isPlainObject } from '@nestjs/common/utils/shared.utils'; + +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { + ObjectMetadataMap, + ObjectMetadataMapItem, +} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; +import { computeRelationType } from 'src/engine/twenty-orm/utils/compute-relation-type.util'; +import { getCompositeFieldMetadataCollection } from 'src/engine/twenty-orm/utils/get-composite-field-metadata-collection'; +import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; + +export function formatResult( + data: T, + objectMetadata: ObjectMetadataMapItem, + objectMetadataMap: ObjectMetadataMap, +): T { + if (!data) { + return data; + } + + if (Array.isArray(data)) { + return data.map((item) => + formatResult(item, objectMetadata, objectMetadataMap), + ) as T; + } + + if (!isPlainObject(data)) { + return data; + } + + if (!objectMetadata) { + throw new Error('Object metadata is missing'); + } + + const compositeFieldMetadataCollection = + getCompositeFieldMetadataCollection(objectMetadata); + + const compositeFieldMetadataMap = new Map( + compositeFieldMetadataCollection.flatMap((fieldMetadata) => { + const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); + + if (!compositeType) return []; + + // Map each composite property to a [key, value] pair + return compositeType.properties.map((compositeProperty) => [ + computeCompositeColumnName(fieldMetadata.name, compositeProperty), + { + parentField: fieldMetadata.name, + ...compositeProperty, + }, + ]); + }), + ); + + const relationMetadataMap = new Map( + Object.values(objectMetadata.fields) + .filter(({ type }) => isRelationFieldMetadataType(type)) + .map((fieldMetadata) => [ + fieldMetadata.name, + { + relationMetadata: + fieldMetadata.fromRelationMetadata ?? + fieldMetadata.toRelationMetadata, + relationType: computeRelationType( + fieldMetadata, + fieldMetadata.fromRelationMetadata ?? + (fieldMetadata.toRelationMetadata as RelationMetadataEntity), + ), + }, + ]), + ); + const newData: object = {}; + + for (const [key, value] of Object.entries(data)) { + const compositePropertyArgs = compositeFieldMetadataMap.get(key); + const { relationMetadata, relationType } = + relationMetadataMap.get(key) ?? {}; + + if (!compositePropertyArgs && !relationMetadata) { + if (isPlainObject(value)) { + newData[key] = formatResult(value, objectMetadata, objectMetadataMap); + } else { + newData[key] = value; + } + continue; + } + + if (relationMetadata) { + const toObjectMetadata = + objectMetadataMap[relationMetadata.toObjectMetadataId]; + + const fromObjectMetadata = + objectMetadataMap[relationMetadata.fromObjectMetadataId]; + + if (!toObjectMetadata) { + throw new Error( + `Object metadata for object metadataId "${relationMetadata.toObjectMetadataId}" is missing`, + ); + } + + if (!fromObjectMetadata) { + throw new Error( + `Object metadata for object metadataId "${relationMetadata.fromObjectMetadataId}" is missing`, + ); + } + + newData[key] = formatResult( + value, + relationType === 'one-to-many' ? toObjectMetadata : fromObjectMetadata, + objectMetadataMap, + ); + continue; + } + + if (!compositePropertyArgs) { + continue; + } + + const { parentField, ...compositeProperty } = compositePropertyArgs; + + if (!newData[parentField]) { + newData[parentField] = {}; + } + + newData[parentField][compositeProperty.name] = value; + } + + return newData as T; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-composite-field-metadata-collection.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-composite-field-metadata-collection.ts new file mode 100644 index 000000000..88ac2820c --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-composite-field-metadata-collection.ts @@ -0,0 +1,12 @@ +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; + +export function getCompositeFieldMetadataCollection( + objectMetadata: ObjectMetadataMapItem, +) { + const compositeFieldMetadataCollection = Object.values( + objectMetadata.fields, + ).filter((fieldMetadata) => isCompositeFieldMetadataType(fieldMetadata.type)); + + return compositeFieldMetadataCollection; +}