Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactored query result getter handlers to support recursivity #8497

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export class GraphqlQueryRunnerService {
results,
objectMetadataItem,
authContext.workspace.id,
options.objectMetadataMap,
);

const resultWithGettersArray = Array.isArray(resultWithGetters)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';

import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { LogExecutionTime } from 'src/engine/decorators/observability/log-execution-time.decorator';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';

export class PersonQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(private readonly fileService: FileService) {}

@LogExecutionTime('PersonQueryResultGetterHandler.handle')
async handle(
person: PersonWorkspaceEntity,
workspaceId: string,
): Promise<PersonWorkspaceEntity> {
console.log('PersonQueryResultGetterHandler.handle');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider removing this console.log or replacing with proper logger since execution time is already being logged via decorator


if (!person.id || !person?.avatarUrl) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Optional chaining (?.) is redundant here since person.id is already checked

return person;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { Record as ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';

export interface QueryResultGetterHandlerInterface {
handle(result: any, workspaceId: string): Promise<any>;
handle(
objectRecord: ObjectRecord,
workspaceId: string,
): Promise<ObjectRecord>;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
import { Injectable } from '@nestjs/common';

import { Record as ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
import { IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';

import { ActivityQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler';
import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler';
import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler';
import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler';
import {
isPossibleFieldValueAConnection,
isPossibleFieldValueANestedRecordArray,
isPossibleFieldValueARecordArray,
} from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/utils/isResultAConnection';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { LogExecutionTime } from 'src/engine/decorators/observability/log-execution-time.decorator';
import { ObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
import { isDefined } from 'src/utils/is-defined';

export type PossibleQueryResultFieldValue =
| IConnection<ObjectRecord>
| { records: ObjectRecord[] }
| ObjectRecord
| ObjectRecord[];

// TODO: find a way to prevent conflict between handlers executing logic on object relations
// And this factory that is also executing logic on object relations
// Right now the factory will override any change made on relations by the handlers
@Injectable()
export class QueryResultGettersFactory {
private handlers: Map<string, QueryResultGetterHandlerInterface>;
Expand All @@ -30,37 +51,189 @@ export class QueryResultGettersFactory {
]);
}

async create(
result: any,
objectMetadataItem: ObjectMetadataInterface,
async processConnection(
connection: IConnection<ObjectRecord>,
objectMetadataItemId: string,
objectMetadataMap: ObjectMetadataMap,
workspaceId: string,
): Promise<any> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: processConnection returns Promise while having strongly typed input parameters. Consider using concrete return type.

const handler = this.getHandler(objectMetadataItem.nameSingular);

if (result.edges) {
return {
...result,
edges: await Promise.all(
result.edges.map(async (edge: any) => ({
...edge,
node: await handler.handle(edge.node, workspaceId),
})),
return {
...connection,
edges: await Promise.all(
connection.edges.map(async (edge: IEdge<ObjectRecord>) => ({
...edge,
node: await this.processRecord(
edge.node,
objectMetadataItemId,
objectMetadataMap,
workspaceId,
),
})),
),
};
}

async processNestedRecordArray(
result: { records: ObjectRecord[] },
objectMetadataItemId: string,
objectMetadataMap: ObjectMetadataMap,
workspaceId: string,
) {
return {
...result,
records: await Promise.all(
result.records.map(
async (record: ObjectRecord) =>
await this.processRecord(
record,
objectMetadataItemId,
objectMetadataMap,
workspaceId,
),
),
};
}
),
};
}

if (result.records) {
return {
...result,
records: await Promise.all(
result.records.map(
async (item: any) => await handler.handle(item, workspaceId),
async processRecordArray(
recordArray: ObjectRecord[],
objectMetadataItemId: string,
objectMetadataMap: ObjectMetadataMap,
workspaceId: string,
) {
return await Promise.all(
recordArray.map(
async (record: ObjectRecord) =>
await this.processRecord(
record,
objectMetadataItemId,
objectMetadataMap,
workspaceId,
),
),
};
),
);
}

async processRecord(
record: ObjectRecord,
objectMetadataItemId: string,
objectMetadataMap: ObjectMetadataMap,
workspaceId: string,
): Promise<ObjectRecord> {
const objectMetadataMapItem = objectMetadataMap[objectMetadataItemId];

const handler = this.getHandler(objectMetadataMapItem.nameSingular);

const relationFields = Object.keys(record)
.map((recordFieldName) => objectMetadataMapItem.fields[recordFieldName])
.filter(isDefined)
.filter((fieldMetadata) =>
isRelationFieldMetadataType(fieldMetadata.type),
);

const relationFieldsProcessedMap = {} as Record<
string,
PossibleQueryResultFieldValue
>;

for (const relationField of relationFields) {
const relationMetadata =
relationField.fromRelationMetadata ?? relationField.toRelationMetadata;

if (!isDefined(relationMetadata)) {
throw new Error('Relation metadata is not defined');
}

// TODO: computing this by taking the opposite of the current object metadata id
// is really less than ideal. This should be computed based on the relation metadata
// But right now it is too complex with the current structure and / or lack of utils
// around the possible combinations with relation metadata from / to + MANY_TO_ONE / ONE_TO_MANY
const relationObjectMetadataItemId =
relationMetadata.fromObjectMetadataId === objectMetadataItemId
? relationMetadata.toObjectMetadataId
: relationMetadata.fromObjectMetadataId;

const relationObjectMetadataItem =
objectMetadataMap[relationObjectMetadataItemId];

if (!isDefined(relationObjectMetadataItem)) {
throw new Error(
`Object metadata not found for id ${relationObjectMetadataItemId}`,
);
}

relationFieldsProcessedMap[relationField.name] =
await this.processQueryResultField(
record[relationField.name],
relationObjectMetadataItem.id,
objectMetadataMap,
workspaceId,
);
}

const objectRecordProcessedWithoutRelationFields = await handler.handle(
record,
workspaceId,
);

const newRecord = {
...objectRecordProcessedWithoutRelationFields,
...relationFieldsProcessedMap,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Handler results are being overridden by relationFieldsProcessedMap spread. This could cause loss of handler-specific processing for relation fields.


return newRecord;
}

async processQueryResultField(
queryResultField: PossibleQueryResultFieldValue,
objectMetadataItemId: string,
objectMetadataMap: ObjectMetadataMap,
workspaceId: string,
) {
if (isPossibleFieldValueAConnection(queryResultField)) {
return await this.processConnection(
queryResultField,
objectMetadataItemId,
objectMetadataMap,
workspaceId,
);
} else if (isPossibleFieldValueANestedRecordArray(queryResultField)) {
return await this.processNestedRecordArray(
queryResultField,
objectMetadataItemId,
objectMetadataMap,
workspaceId,
);
} else if (isPossibleFieldValueARecordArray(queryResultField)) {
return await this.processRecordArray(
queryResultField,
objectMetadataItemId,
objectMetadataMap,
workspaceId,
);
} else {
return await this.processRecord(
queryResultField,
objectMetadataItemId,
objectMetadataMap,
workspaceId,
);
}
}

return await handler.handle(result, workspaceId);
@LogExecutionTime('QueryResultGettersFactory.create')
async create(
result: PossibleQueryResultFieldValue,
objectMetadataItem: ObjectMetadataInterface,
workspaceId: string,
objectMetadataMap: ObjectMetadataMap,
): Promise<any> {
return await this.processQueryResultField(
result,
objectMetadataItem.id,
objectMetadataMap,
workspaceId,
);
}

private getHandler(objectType: string): QueryResultGetterHandlerInterface {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isDefined } from 'class-validator';

import { Record as ObjectRecord } 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 { IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';

import { PossibleQueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/query-result-getters.factory';

export const isPossibleFieldValueAConnection = (
result: PossibleQueryResultFieldValue,
): result is IConnection<ObjectRecord, IEdge<ObjectRecord>> => {
return isDefined((result as any).edges);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Using (result as any).edges bypasses type safety. Consider checking if result is an object first with typeof result === 'object' && result !== null

};

export const isPossibleFieldValueANestedRecordArray = (
result: PossibleQueryResultFieldValue,
): result is { records: ObjectRecord[] } => {
return 'records' in result && Array.isArray(result.records);
};

export const isPossibleFieldValueARecordArray = (
result: PossibleQueryResultFieldValue,
): result is ObjectRecord[] => {
return Array.isArray(result);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
Expand All @@ -20,6 +21,7 @@ import { EntityEventsToDbListener } from './listeners/entity-events-to-db.listen

@Module({
imports: [
WorkspaceMetadataCacheModule,
AuthModule,
WorkspaceQueryBuilderModule,
WorkspaceDataSourceModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Logger } from '@nestjs/common';

import { isDefined } from 'class-validator';

/**
* A decorator function that logs the execution time of the decorated method.
*
* @returns The modified property descriptor with the execution time logging functionality.
*/
export function LogExecutionTime() {
export function LogExecutionTime(label?: string | undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: The type annotation string | undefined is redundant when using optional parameter syntax ?

return function (
target: any,
propertyKey: string,
Expand All @@ -21,7 +23,11 @@ export function LogExecutionTime() {
const end = performance.now();
const executionTime = end - start;

logger.log(`Execution time: ${executionTime.toFixed(2)}ms`);
if (isDefined(label)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: isDefined() will return true for empty strings - consider using a more strict check like label?.length > 0

logger.log(`${label} execution time: ${executionTime.toFixed(2)}ms`);
} else {
logger.log(`Execution time: ${executionTime.toFixed(2)}ms`);
}

return result;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { RelationMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/relation-metadata/interceptors/relation-metadata-graphql-api-exception.interceptor';
import { RelationMetadataResolver } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.resolver';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
Expand All @@ -39,6 +40,7 @@ import { RelationMetadataDTO } from './dtos/relation-metadata.dto';
WorkspaceMigrationModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataVersionModule,
WorkspaceMetadataCacheModule,
],
services: [RelationMetadataService],
resolvers: [
Expand Down
Loading
Loading