Fix nested relations (#7158)

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Weiko
2024-09-20 05:16:13 +02:00
committed by GitHub
parent 6a5f9492d3
commit b1889e4569
17 changed files with 753 additions and 531 deletions

View File

@ -6,6 +6,7 @@ import { RecordChip } from '@/object-record/components/RecordChip';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay'; import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { isNull } from '@sniptt/guards';
export const RelationFromManyFieldDisplay = () => { export const RelationFromManyFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay(); const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay();
@ -47,37 +48,43 @@ export const RelationFromManyFieldDisplay = () => {
return ( return (
<ExpandableList isChipCountDisplayed={isFocused}> <ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue.map((record) => ( {fieldValue
<RecordChip .filter((record) => !isNull(record[relationFieldName]))
key={record.id} .map((record) => (
objectNameSingular={objectNameSingular} <RecordChip
record={record[relationFieldName]} key={record.id}
/> objectNameSingular={objectNameSingular}
))} record={record[relationFieldName]}
/>
))}
</ExpandableList> </ExpandableList>
); );
} else if (isRelationFromActivityTargets) { } else if (isRelationFromActivityTargets) {
return ( return (
<ExpandableList isChipCountDisplayed={isFocused}> <ExpandableList isChipCountDisplayed={isFocused}>
{activityTargetObjectRecords.map((record) => ( {activityTargetObjectRecords
<RecordChip .filter((record) => !isNull(record.targetObject))
key={record.targetObject.id} .map((record) => (
objectNameSingular={record.targetObjectMetadataItem.nameSingular} <RecordChip
record={record.targetObject} key={record.targetObject.id}
/> objectNameSingular={record.targetObjectMetadataItem.nameSingular}
))} record={record.targetObject}
/>
))}
</ExpandableList> </ExpandableList>
); );
} else { } else {
return ( return (
<ExpandableList isChipCountDisplayed={isFocused}> <ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue.map((record) => ( {fieldValue
<RecordChip .filter((record) => !isNull(record))
key={record.id} .map((record) => (
objectNameSingular={relationObjectNameSingular} <RecordChip
record={record} key={record.id}
/> objectNameSingular={relationObjectNameSingular}
))} record={record}
/>
))}
</ExpandableList> </ExpandableList>
); );
} }

View File

@ -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'; 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 { export class GraphqlQueryFilterConditionParser {
private fieldMetadataMap: FieldMetadataMap; private fieldMetadataMap: FieldMetadataMap;
private fieldConditionParser: GraphqlQueryFilterFieldParser; private queryFilterFieldParser: GraphqlQueryFilterFieldParser;
constructor(fieldMetadataMap: FieldMetadataMap) { constructor(fieldMetadataMap: FieldMetadataMap) {
this.fieldMetadataMap = fieldMetadataMap; this.fieldMetadataMap = fieldMetadataMap;
this.fieldConditionParser = new GraphqlQueryFilterFieldParser( this.queryFilterFieldParser = new GraphqlQueryFilterFieldParser(
this.fieldMetadataMap, this.fieldMetadataMap,
); );
} }
public parse( public parse(
conditions: RecordFilter, queryBuilder: SelectQueryBuilder<any>,
isNegated = false, objectNameSingular: string,
): FindOptionsWhere<ObjectLiteral> | FindOptionsWhere<ObjectLiteral>[] { filter: RecordFilter,
if (Array.isArray(conditions)) { ): SelectQueryBuilder<any> {
return this.parseAndCondition(conditions, isNegated); if (!filter || Object.keys(filter).length === 0) {
return queryBuilder;
} }
const result: FindOptionsWhere<ObjectLiteral> = {}; 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)) { private parseKeyFilter(
switch (key) { queryBuilder: WhereExpressionBuilder,
case 'and': { objectNameSingular: string,
const andConditions = this.parseAndCondition(value, isNegated); 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) => ({ if (index === 0) {
...result, qb.where(whereCondition);
...condition, } else {
})); qb.andWhere(whereCondition);
}
});
});
if (isFirst) {
queryBuilder.where(andWhereCondition);
} else {
queryBuilder.andWhere(andWhereCondition);
} }
case 'or': { break;
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),
);
} }
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<ObjectLiteral>[] {
const parsedConditions = conditions.map((condition) =>
this.parse(condition, isNegated),
);
return this.combineConditions(parsedConditions, isNegated ? 'or' : 'and');
}
private parseOrCondition(
conditions: RecordFilter[],
isNegated: boolean,
): FindOptionsWhere<ObjectLiteral>[] {
const parsedConditions = conditions.map((condition) =>
this.parse(condition, isNegated),
);
return this.combineConditions(parsedConditions, isNegated ? 'and' : 'or');
}
private combineConditions(
conditions: (
| FindOptionsWhere<ObjectLiteral>
| FindOptionsWhere<ObjectLiteral>[]
)[],
combineType: 'and' | 'or',
): FindOptionsWhere<ObjectLiteral>[] {
if (combineType === 'and') {
return conditions.reduce<FindOptionsWhere<ObjectLiteral>[]>(
(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],
);
} }
} }

View File

@ -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 { 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 { 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 { 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 { 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 { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { capitalize } from 'src/utils/capitalize'; import { capitalize } from 'src/utils/capitalize';
import { isPlainObject } from 'src/utils/is-plain-object';
import { GraphqlQueryFilterConditionParser } from './graphql-query-filter-condition.parser'; type WhereConditionParts = {
import { GraphqlQueryFilterOperatorParser } from './graphql-query-filter-operator.parser'; sql: string;
params: ObjectLiteral;
};
export class GraphqlQueryFilterFieldParser { export class GraphqlQueryFilterFieldParser {
private fieldMetadataMap: FieldMetadataMap; private fieldMetadataMap: FieldMetadataMap;
private operatorParser: GraphqlQueryFilterOperatorParser;
constructor(fieldMetadataMap: FieldMetadataMap) { constructor(fieldMetadataMap: FieldMetadataMap) {
this.fieldMetadataMap = fieldMetadataMap; this.fieldMetadataMap = fieldMetadataMap;
this.operatorParser = new GraphqlQueryFilterOperatorParser();
} }
public parse( public parse(
queryBuilder: WhereExpressionBuilder,
objectNameSingular: string,
key: string, key: string,
value: any, filterValue: any,
isNegated: boolean, isFirst = false,
): FindOptionsWhere<ObjectLiteral> { ): void {
const fieldMetadata = this.fieldMetadataMap[key]; const fieldMetadata = this.fieldMetadataMap[`${key}`];
if (!fieldMetadata) { if (!fieldMetadata) {
return { throw new Error(`Field metadata not found for field: ${key}`);
[key]: (value: RecordFilter, isNegated: boolean) => {
const conditionParser = new GraphqlQueryFilterConditionParser(
this.fieldMetadataMap,
);
return conditionParser.parse(value, isNegated);
},
};
} }
if (isCompositeFieldMetadataType(fieldMetadata.type)) { 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 { sql, params } = this.computeWhereConditionParts(
const parsedValue = this.operatorParser.parseOperator(value, isNegated); 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( private parseCompositeFieldForFilter(
queryBuilder: WhereExpressionBuilder,
fieldMetadata: FieldMetadataInterface, fieldMetadata: FieldMetadataInterface,
objectNameSingular: string,
fieldValue: any, fieldValue: any,
isNegated: boolean, isFirst = false,
): FindOptionsWhere<ObjectLiteral> { ): void {
const compositeType = compositeTypeDefinitions.get( const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type as CompositeFieldMetadataType, fieldMetadata.type as CompositeFieldMetadataType,
); );
@ -69,34 +158,36 @@ export class GraphqlQueryFilterFieldParser {
); );
} }
return Object.entries(fieldValue).reduce( Object.entries(fieldValue).map(([subFieldKey, subFieldFilter], index) => {
(result, [subFieldKey, subFieldValue]) => { const subFieldMetadata = compositeType.properties.find(
const subFieldMetadata = compositeType.properties.find( (property) => property.name === subFieldKey,
(property) => property.name === subFieldKey, );
if (!subFieldMetadata) {
throw new Error(
`Sub field metadata not found for composite type: ${fieldMetadata.type}`,
); );
}
if (!subFieldMetadata) { const fullFieldName = `${fieldMetadata.name}${capitalize(subFieldKey)}`;
throw new Error(
`Sub field metadata not found for composite type: ${fieldMetadata.type}`,
);
}
const fullFieldName = `${fieldMetadata.name}${capitalize(subFieldKey)}`; const [[operator, value]] = Object.entries(
subFieldFilter as Record<string, any>,
);
if (isPlainObject(subFieldValue)) { const { sql, params } = this.computeWhereConditionParts(
result[fullFieldName] = this.operatorParser.parseOperator( fieldMetadata,
subFieldValue, operator,
isNegated, objectNameSingular,
); fullFieldName,
} else { value,
result[fullFieldName] = isNegated );
? Not(subFieldValue)
: subFieldValue;
}
return result; if (isFirst && index === 0) {
}, queryBuilder.where(sql, params);
{} as FindOptionsWhere<ObjectLiteral>, }
);
queryBuilder.andWhere(sql, params);
});
} }
} }

View File

@ -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<any> };
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<string, any>,
isNegated: boolean,
): FindOperator<any> {
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,
);
}
}

View File

@ -1,5 +1,3 @@
import { FindOptionsOrderValue } from 'typeorm';
import { import {
OrderByDirection, OrderByDirection,
RecordOrderBy, RecordOrderBy,
@ -24,8 +22,9 @@ export class GraphqlQueryOrderFieldParser {
parse( parse(
orderBy: RecordOrderBy, orderBy: RecordOrderBy,
objectNameSingular: string,
isForwardPagination = true, isForwardPagination = true,
): Record<string, FindOptionsOrderValue> { ): Record<string, string> {
return orderBy.reduce( return orderBy.reduce(
(acc, item) => { (acc, item) => {
Object.entries(item).forEach(([key, value]) => { Object.entries(item).forEach(([key, value]) => {
@ -42,29 +41,29 @@ export class GraphqlQueryOrderFieldParser {
const compositeOrder = this.parseCompositeFieldForOrder( const compositeOrder = this.parseCompositeFieldForOrder(
fieldMetadata, fieldMetadata,
value, value,
objectNameSingular,
isForwardPagination, isForwardPagination,
); );
Object.assign(acc, compositeOrder); Object.assign(acc, compositeOrder);
} else { } else {
acc[key] = this.convertOrderByToFindOptionsOrder( acc[`"${objectNameSingular}"."${key}"`] =
value, this.convertOrderByToFindOptionsOrder(value, isForwardPagination);
isForwardPagination,
);
} }
}); });
return acc; return acc;
}, },
{} as Record<string, FindOptionsOrderValue>, {} as Record<string, string>,
); );
} }
private parseCompositeFieldForOrder( private parseCompositeFieldForOrder(
fieldMetadata: FieldMetadataInterface, fieldMetadata: FieldMetadataInterface,
value: any, value: any,
objectNameSingular: string,
isForwardPagination = true, isForwardPagination = true,
): Record<string, FindOptionsOrderValue> { ): Record<string, string> {
const compositeType = compositeTypeDefinitions.get( const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type as CompositeFieldMetadataType, 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)) { if (!this.isOrderByDirection(subFieldValue)) {
throw new Error( throw new Error(
@ -101,35 +100,23 @@ export class GraphqlQueryOrderFieldParser {
return acc; return acc;
}, },
{} as Record<string, FindOptionsOrderValue>, {} as Record<string, string>,
); );
} }
private convertOrderByToFindOptionsOrder( private convertOrderByToFindOptionsOrder(
direction: OrderByDirection, direction: OrderByDirection,
isForwardPagination = true, isForwardPagination = true,
): FindOptionsOrderValue { ): string {
switch (direction) { switch (direction) {
case OrderByDirection.AscNullsFirst: case OrderByDirection.AscNullsFirst:
return { return `${isForwardPagination ? 'ASC' : 'DESC'} NULLS FIRST`;
direction: isForwardPagination ? 'ASC' : 'DESC',
nulls: 'FIRST',
};
case OrderByDirection.AscNullsLast: case OrderByDirection.AscNullsLast:
return { return `${isForwardPagination ? 'ASC' : 'DESC'} NULLS LAST`;
direction: isForwardPagination ? 'ASC' : 'DESC',
nulls: 'LAST',
};
case OrderByDirection.DescNullsFirst: case OrderByDirection.DescNullsFirst:
return { return `${isForwardPagination ? 'DESC' : 'ASC'} NULLS FIRST`;
direction: isForwardPagination ? 'DESC' : 'ASC',
nulls: 'FIRST',
};
case OrderByDirection.DescNullsLast: case OrderByDirection.DescNullsLast:
return { return `${isForwardPagination ? 'DESC' : 'ASC'} NULLS LAST`;
direction: isForwardPagination ? 'DESC' : 'ASC',
nulls: 'LAST',
};
default: default:
throw new GraphqlQueryRunnerException( throw new GraphqlQueryRunnerException(
`Invalid direction: ${direction}`, `Invalid direction: ${direction}`,

View File

@ -1,7 +1,8 @@
import { import {
FindOptionsOrderValue,
FindOptionsWhere, FindOptionsWhere,
ObjectLiteral, ObjectLiteral,
OrderByCondition,
SelectQueryBuilder,
} from 'typeorm'; } from 'typeorm';
import { import {
@ -10,8 +11,8 @@ import {
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; } 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 { 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 { GraphqlQueryFilterConditionParser } 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 { 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 { GraphqlQuerySelectedFieldsParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { import {
FieldMetadataMap, FieldMetadataMap,
@ -21,6 +22,8 @@ import {
export class GraphqlQueryParser { export class GraphqlQueryParser {
private fieldMetadataMap: FieldMetadataMap; private fieldMetadataMap: FieldMetadataMap;
private objectMetadataMap: ObjectMetadataMap; private objectMetadataMap: ObjectMetadataMap;
private filterConditionParser: GraphqlQueryFilterConditionParser;
private orderFieldParser: GraphqlQueryOrderFieldParser;
constructor( constructor(
fieldMetadataMap: FieldMetadataMap, fieldMetadataMap: FieldMetadataMap,
@ -28,33 +31,44 @@ export class GraphqlQueryParser {
) { ) {
this.objectMetadataMap = objectMetadataMap; this.objectMetadataMap = objectMetadataMap;
this.fieldMetadataMap = fieldMetadataMap; this.fieldMetadataMap = fieldMetadataMap;
} this.filterConditionParser = new GraphqlQueryFilterConditionParser(
parseFilter(recordFilter: RecordFilter): {
parsedFilters:
| FindOptionsWhere<ObjectLiteral>
| FindOptionsWhere<ObjectLiteral>[];
withDeleted: boolean;
} {
const graphqlQueryFilterParser = new GraphqlQueryFilterParser(
this.fieldMetadataMap, this.fieldMetadataMap,
); );
this.orderFieldParser = new GraphqlQueryOrderFieldParser(
this.fieldMetadataMap,
);
}
const parsedFilter = graphqlQueryFilterParser.parse(recordFilter); applyFilterToBuilder(
queryBuilder: SelectQueryBuilder<any>,
objectNameSingular: string,
recordFilter: RecordFilter,
): SelectQueryBuilder<any> {
return this.filterConditionParser.parse(
queryBuilder,
objectNameSingular,
recordFilter,
);
}
const hasDeletedAtFilter = this.checkForDeletedAtFilter(parsedFilter); applyDeletedAtToBuilder(
queryBuilder: SelectQueryBuilder<any>,
recordFilter: RecordFilter,
): SelectQueryBuilder<any> {
if (this.checkForDeletedAtFilter(recordFilter)) {
queryBuilder.withDeleted();
}
return { return queryBuilder;
parsedFilters: parsedFilter,
withDeleted: hasDeletedAtFilter,
};
} }
private checkForDeletedAtFilter( private checkForDeletedAtFilter(
filter: FindOptionsWhere<ObjectLiteral> | FindOptionsWhere<ObjectLiteral>[], filter: FindOptionsWhere<ObjectLiteral> | FindOptionsWhere<ObjectLiteral>[],
): boolean { ): boolean {
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
return filter.some(this.checkForDeletedAtFilter); return filter.some((subFilter) =>
this.checkForDeletedAtFilter(subFilter),
);
} }
for (const [key, value] of Object.entries(filter)) { for (const [key, value] of Object.entries(filter)) {
@ -74,15 +88,19 @@ export class GraphqlQueryParser {
return false; return false;
} }
parseOrder( applyOrderToBuilder(
queryBuilder: SelectQueryBuilder<any>,
orderBy: RecordOrderBy, orderBy: RecordOrderBy,
objectNameSingular: string,
isForwardPagination = true, isForwardPagination = true,
): Record<string, FindOptionsOrderValue> { ): SelectQueryBuilder<any> {
const graphqlQueryOrderParser = new GraphqlQueryOrderParser( const parsedOrderBys = this.orderFieldParser.parse(
this.fieldMetadataMap, orderBy,
objectNameSingular,
isForwardPagination,
); );
return graphqlQueryOrderParser.parse(orderBy, isForwardPagination); return queryBuilder.orderBy(parsedOrderBys as OrderByCondition);
} }
parseSelectedFields( parseSelectedFields(

View File

@ -77,7 +77,7 @@ export class ProcessNestedRelationsHelper {
if (Object.keys(nestedRelations).length > 0) { if (Object.keys(nestedRelations).length > 0) {
await this.processNestedRelations( await this.processNestedRelations(
objectMetadataMap, objectMetadataMap,
objectMetadataMap[relationName], objectMetadataMap[referenceObjectMetadataName],
relationResults as ObjectRecord[], relationResults as ObjectRecord[],
nestedRelations as Record<string, FindOptionsRelations<ObjectLiteral>>, nestedRelations as Record<string, FindOptionsRelations<ObjectLiteral>>,
limit, limit,
@ -126,6 +126,9 @@ export class ProcessNestedRelationsHelper {
const relationResults = await relationRepository.find(relationFindOptions); const relationResults = await relationRepository.find(relationFindOptions);
parentObjectRecords.forEach((item) => { parentObjectRecords.forEach((item) => {
if (relationResults.length === 0) {
(item as any)[`${relationName}Id`] = null;
}
(item as any)[relationName] = relationResults.filter( (item as any)[relationName] = relationResults.filter(
(rel) => rel.id === item[`${relationName}Id`], (rel) => rel.id === item[`${relationName}Id`],
)[0]; )[0];
@ -134,7 +137,7 @@ export class ProcessNestedRelationsHelper {
if (Object.keys(nestedRelations).length > 0) { if (Object.keys(nestedRelations).length > 0) {
await this.processNestedRelations( await this.processNestedRelations(
objectMetadataMap, objectMetadataMap,
objectMetadataMap[relationName], objectMetadataMap[referenceObjectMetadataName],
relationResults as ObjectRecord[], relationResults as ObjectRecord[],
nestedRelations as Record<string, FindOptionsRelations<ObjectLiteral>>, nestedRelations as Record<string, FindOptionsRelations<ObjectLiteral>>,
limit, limit,

View File

@ -1,6 +1,7 @@
import { FindOptionsOrderValue } from 'typeorm'; import {
Record as IRecord,
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; 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 { 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'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
@ -31,7 +32,7 @@ export class ObjectRecordsToGraphqlConnectionMapper {
objectName: string, objectName: string,
take: number, take: number,
totalCount: number, totalCount: number,
order: Record<string, FindOptionsOrderValue> | undefined, order: RecordOrderBy | undefined,
hasNextPage: boolean, hasNextPage: boolean,
hasPreviousPage: boolean, hasPreviousPage: boolean,
depth = 0, depth = 0,
@ -65,7 +66,7 @@ export class ObjectRecordsToGraphqlConnectionMapper {
objectName: string, objectName: string,
take: number, take: number,
totalCount: number, totalCount: number,
order: Record<string, FindOptionsOrderValue> | undefined = {}, order?: RecordOrderBy,
depth = 0, depth = 0,
): T { ): T {
if (depth >= CONNECTION_MAX_DEPTH) { if (depth >= CONNECTION_MAX_DEPTH) {

View File

@ -1,9 +1,9 @@
import { isDefined } from 'class-validator'; import { isDefined } from 'class-validator';
import graphqlFields from 'graphql-fields'; import graphqlFields from 'graphql-fields';
import { FindManyOptions, ObjectLiteral } from 'typeorm';
import { import {
Record as IRecord, Record as IRecord,
OrderByDirection,
RecordFilter, RecordFilter,
RecordOrderBy, RecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; } 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 { 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 { 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 { 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 { 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 { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util';
import { import {
generateObjectMetadataMap,
ObjectMetadataMapItem, ObjectMetadataMapItem,
generateObjectMetadataMap,
} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; 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 { export class GraphqlQueryFindManyResolverService {
private twentyORMGlobalManager: TwentyORMGlobalManager; private twentyORMGlobalManager: TwentyORMGlobalManager;
@ -56,9 +57,19 @@ export class GraphqlQueryFindManyResolverService {
const repository = dataSource.getRepository( const repository = dataSource.getRepository(
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
); );
const queryBuilder = repository.createQueryBuilder(
objectMetadataItem.nameSingular,
);
const countQueryBuilder = repository.createQueryBuilder(
objectMetadataItem.nameSingular,
);
const objectMetadataMap = generateObjectMetadataMap( const objectMetadataMap = generateObjectMetadataMap(
objectMetadataCollection, objectMetadataCollection,
); );
const objectMetadata = getObjectMetadataOrThrow( const objectMetadata = getObjectMetadataOrThrow(
objectMetadataMap, objectMetadataMap,
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
@ -68,45 +79,82 @@ export class GraphqlQueryFindManyResolverService {
objectMetadataMap, objectMetadataMap,
); );
const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
countQueryBuilder,
objectMetadataItem.nameSingular,
args.filter ?? ({} as Filter),
);
const selectedFields = graphqlFields(info); const selectedFields = graphqlFields(info);
const { select, relations } = graphqlQueryParser.parseSelectedFields( const { relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataItem, objectMetadataItem,
selectedFields, selectedFields,
); );
const isForwardPagination = !isDefined(args.before); 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; const limit = args.first ?? args.last ?? QUERY_MAX_RECORDS;
this.addOrderByColumnsToSelect(order, select); const withDeletedCountQueryBuilder =
this.addForeingKeyColumnsToSelect(relations, select, objectMetadata); graphqlQueryParser.applyDeletedAtToBuilder(
withFilterCountQueryBuilder,
const findOptions: FindManyOptions<ObjectLiteral> = { args.filter ?? ({} as Filter),
where, );
order,
select,
take: limit + 1,
withDeleted,
};
const totalCount = isDefined(selectedFields.totalCount) const totalCount = isDefined(selectedFields.totalCount)
? await repository.count({ where, withDeleted }) ? await withDeletedCountQueryBuilder.getCount()
: 0; : 0;
const cursor = this.getCursor(args);
let appliedFilters = args.filter ?? ({} as Filter);
const orderByWithIdCondition = [
...(args.orderBy ?? []),
{ id: OrderByDirection.AscNullsFirst },
] as OrderBy;
if (cursor) { 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( const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
findOptions, queryBuilder,
)) as ObjectRecord[]; 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( const { hasNextPage, hasPreviousPage } = this.getPaginationInfo(
objectRecords, objectRecords,
@ -142,7 +190,7 @@ export class GraphqlQueryFindManyResolverService {
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
limit, limit,
totalCount, totalCount,
order, orderByWithIdCondition,
hasNextPage, hasNextPage,
hasPreviousPage, hasPreviousPage,
); );

View File

@ -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 { 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 { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; 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 { export class GraphqlQueryFindOneResolverService {
private twentyORMGlobalManager: TwentyORMGlobalManager; private twentyORMGlobalManager: TwentyORMGlobalManager;
@ -35,12 +36,17 @@ export class GraphqlQueryFindOneResolverService {
): Promise<ObjectRecord | undefined> { ): Promise<ObjectRecord | undefined> {
const { authContext, objectMetadataItem, info, objectMetadataCollection } = const { authContext, objectMetadataItem, info, objectMetadataCollection } =
options; options;
const dataSource = const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace( await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id, authContext.workspace.id,
); );
const repository = await dataSource.getRepository<ObjectRecord>( const repository = dataSource.getRepository(
objectMetadataItem.nameSingular,
);
const queryBuilder = repository.createQueryBuilder(
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
); );
@ -52,6 +58,7 @@ export class GraphqlQueryFindOneResolverService {
objectMetadataMap, objectMetadataMap,
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
); );
const graphqlQueryParser = new GraphqlQueryParser( const graphqlQueryParser = new GraphqlQueryParser(
objectMetadata.fields, objectMetadata.fields,
objectMetadataMap, objectMetadataMap,
@ -59,18 +66,29 @@ export class GraphqlQueryFindOneResolverService {
const selectedFields = graphqlFields(info); const selectedFields = graphqlFields(info);
const { select, relations } = graphqlQueryParser.parseSelectedFields( const { relations } = graphqlQueryParser.parseSelectedFields(
objectMetadataItem, objectMetadataItem,
selectedFields, selectedFields,
); );
const { parsedFilters: where, withDeleted } =
graphqlQueryParser.parseFilter(args.filter ?? ({} as Filter));
const objectRecord = (await repository.findOne({ const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
where, queryBuilder,
select, objectMetadataItem.nameSingular,
withDeleted, args.filter ?? ({} as Filter),
})) as ObjectRecord; );
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; const limit = QUERY_MAX_RECORDS;

View File

@ -1,16 +0,0 @@
import { FindOptionsWhere, LessThan, MoreThan, ObjectLiteral } from 'typeorm';
export const applyRangeFilter = (
where: FindOptionsWhere<ObjectLiteral>,
cursor: Record<string, any>,
isForwardPagination = true,
): FindOptionsWhere<ObjectLiteral> => {
Object.entries(cursor ?? {}).forEach(([key, value]) => {
if (key === 'id') {
return;
}
where[key] = isForwardPagination ? MoreThan(value) : LessThan(value);
});
return where;
};

View File

@ -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<string, any>,
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;
});
};

View File

@ -1,6 +1,7 @@
import { FindOptionsOrderValue } from 'typeorm'; import {
Record as IRecord,
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; RecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { import {
GraphqlQueryRunnerException, GraphqlQueryRunnerException,
@ -24,11 +25,15 @@ export const decodeCursor = (cursor: string): CursorData => {
export const encodeCursor = <ObjectRecord extends IRecord = IRecord>( export const encodeCursor = <ObjectRecord extends IRecord = IRecord>(
objectRecord: ObjectRecord, objectRecord: ObjectRecord,
order: Record<string, FindOptionsOrderValue> | undefined, order: RecordOrderBy | undefined,
): string => { ): string => {
const orderByValues: Record<string, any> = {}; const orderByValues: Record<string, any> = {};
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]; orderByValues[key] = objectRecord[key];
}); });

View File

@ -1,5 +1,3 @@
import { isPlainObject } from '@nestjs/common/utils/shared.utils';
import { import {
DeepPartial, DeepPartial,
DeleteResult, DeleteResult,
@ -24,14 +22,10 @@ import { UpsertOptions } from 'typeorm/repository/UpsertOptions';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; 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 { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { WorkspaceEntitiesStorage } from 'src/engine/twenty-orm/storage/workspace-entities.storage'; import { WorkspaceEntitiesStorage } from 'src/engine/twenty-orm/storage/workspace-entities.storage';
import { computeRelationType } from 'src/engine/twenty-orm/utils/compute-relation-type.util'; import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
export class WorkspaceRepository< export class WorkspaceRepository<
Entity extends ObjectLiteral, Entity extends ObjectLiteral,
@ -650,18 +644,6 @@ export class WorkspaceRepository<
return objectMetadata; return objectMetadata;
} }
private async getCompositeFieldMetadataCollection(
objectMetadata: ObjectMetadataMapItem,
) {
const compositeFieldMetadataCollection = Object.values(
objectMetadata.fields,
).filter((fieldMetadata) =>
isCompositeFieldMetadataType(fieldMetadata.type),
);
return compositeFieldMetadataCollection;
}
private async transformOptions< private async transformOptions<
T extends FindManyOptions<Entity> | FindOneOptions<Entity> | undefined, T extends FindManyOptions<Entity> | FindOneOptions<Entity> | undefined,
>(options: T): Promise<T> { >(options: T): Promise<T> {
@ -677,62 +659,9 @@ export class WorkspaceRepository<
} }
private async formatData<T>(data: T): Promise<T> { private async formatData<T>(data: T): Promise<T> {
if (!data) {
return data;
}
if (Array.isArray(data)) {
return Promise.all(
data.map((item) => this.formatData(item)),
) as Promise<T>;
}
const objectMetadata = await this.getObjectMetadataFromTarget(); const objectMetadata = await this.getObjectMetadataFromTarget();
const compositeFieldMetadataCollection = return formatData(data, objectMetadata) as T;
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;
} }
private async formatResult<T>( private async formatResult<T>(
@ -741,124 +670,8 @@ export class WorkspaceRepository<
): Promise<T> { ): Promise<T> {
objectMetadata ??= await this.getObjectMetadataFromTarget(); objectMetadata ??= await this.getObjectMetadataFromTarget();
if (!data) { const objectMetadataMap = this.internalContext.objectMetadataMap;
return data;
}
if (Array.isArray(data)) { return formatResult(data, objectMetadata, objectMetadataMap) as T;
// 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<T>;
}
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;
} }
} }

View File

@ -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<T>(
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;
}

View File

@ -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<T>(
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;
}

View File

@ -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;
}