Direct graphql execution (#18759)
On top of [previous closed PR](https://github.com/twentyhq/twenty/pull/18713) from @FelixMalfait : - add a schema-creation-skipping optimization - extract a handler-per-operation pattern, - add runtime input validation guards, - integrate with the standard workspace cache - add gql-style error handling To do/optimize/check : - gql parsing and null backfilling ## Intro This PR introduces a **direct GraphQL execution path** that bypasses per-workspace GraphQL schema generation for workspace data queries (CRUD on user-defined objects like companies, people, tasks, etc.). ## Why In the current architecture, every workspace gets its own dynamically-generated GraphQL schema reflecting its custom objects and fields. This costs **~20MB of RAM per workspace per pod** and takes time to build. For a multi-tenant SaaS with thousands of workspaces, this is a significant infrastructure cost and a latency bottleneck (especially on cold starts or cache misses). The insight is that most workspace queries (`findMany`, `createOne`, `updateOne`, etc.) don't actually *need* the full schema — they can be routed directly to the existing Common API query runners by parsing the GraphQL AST and matching resolver names against object metadata. The schema is only truly needed for introspection, subscriptions, or queries that mix core and workspace resolvers. ## How It Works 1. A Yoga `onRequest` plugin intercepts incoming GraphQL requests 2. It parses the query AST and checks if all top-level fields map to generated workspace resolvers (e.g. `findManyCompanies`, `createOnePerson`) 3. If yes, it executes them directly against the query runners, skipping schema generation entirely 4. If the query contains introspection, subscriptions, or core-only resolvers, it falls through to the normal path 5. Even for mixed queries it can't fully handle, it sets `skipWorkspaceSchemaCreation` to avoid building the schema when unnecessary The whole thing is gated behind the `IS_DIRECT_GRAPHQL_EXECUTION_ENABLED` feature flag for safe incremental rollout. **Net effect**: dramatically lower memory footprint and faster response times for the vast majority of workspace API calls. --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module';
|
||||
import { DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service';
|
||||
import { WorkspaceResolverNameMapCacheService } from 'src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service';
|
||||
import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CoreCommonApiModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceResolverBuilderModule,
|
||||
],
|
||||
providers: [DirectExecutionService, WorkspaceResolverNameMapCacheService],
|
||||
exports: [DirectExecutionService],
|
||||
})
|
||||
export class DirectExecutionModule {}
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { type Request } from 'express';
|
||||
import {
|
||||
GraphQLError,
|
||||
type DocumentNode,
|
||||
type FieldNode,
|
||||
type GraphQLFormattedError,
|
||||
type GraphQLResolveInfo,
|
||||
} from 'graphql';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import graphqlFields from 'graphql-fields';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { assertCreateManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util';
|
||||
import { assertCreateOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util';
|
||||
import { assertDeleteManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util';
|
||||
import { assertDeleteOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util';
|
||||
import { assertDestroyManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util';
|
||||
import { assertDestroyOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util';
|
||||
import { assertFindDuplicatesArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util';
|
||||
import { assertFindManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util';
|
||||
import { assertFindOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util';
|
||||
import { assertGroupByArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util';
|
||||
import { assertMergeManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util';
|
||||
import { assertRestoreManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util';
|
||||
import { assertRestoreOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util';
|
||||
import { assertUpdateManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util';
|
||||
import { assertUpdateOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util';
|
||||
import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util';
|
||||
import { buildWorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util';
|
||||
import { extractArgumentsFromAst } from 'src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util';
|
||||
import { graphQLBackfillNullsFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util';
|
||||
import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util';
|
||||
import { graphQLBuildPartialResolveInfo } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util';
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
|
||||
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
|
||||
import { CreateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory';
|
||||
import { CreateOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory';
|
||||
import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory';
|
||||
import { DeleteOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory';
|
||||
import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory';
|
||||
import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory';
|
||||
import { FindDuplicatesResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory';
|
||||
import { FindManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory';
|
||||
import { FindOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory';
|
||||
import { GroupByResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory';
|
||||
import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory';
|
||||
import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory';
|
||||
import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory';
|
||||
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
|
||||
import { UpdateOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory';
|
||||
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
|
||||
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
type DirectExecutionResult = {
|
||||
data: Record<string, unknown> | null;
|
||||
errors?: GraphQLFormattedError[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DirectExecutionService {
|
||||
private readonly factoryMap: Map<
|
||||
string,
|
||||
WorkspaceResolverBuilderFactoryInterface
|
||||
>;
|
||||
|
||||
private readonly argsAssertionMap: Map<string, (args: unknown) => void>;
|
||||
|
||||
constructor(
|
||||
private readonly workspaceFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly findManyResolverFactory: FindManyResolverFactory,
|
||||
private readonly findOneResolverFactory: FindOneResolverFactory,
|
||||
private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory,
|
||||
private readonly groupByResolverFactory: GroupByResolverFactory,
|
||||
private readonly createOneResolverFactory: CreateOneResolverFactory,
|
||||
private readonly createManyResolverFactory: CreateManyResolverFactory,
|
||||
private readonly updateOneResolverFactory: UpdateOneResolverFactory,
|
||||
private readonly updateManyResolverFactory: UpdateManyResolverFactory,
|
||||
private readonly deleteOneResolverFactory: DeleteOneResolverFactory,
|
||||
private readonly deleteManyResolverFactory: DeleteManyResolverFactory,
|
||||
private readonly destroyOneResolverFactory: DestroyOneResolverFactory,
|
||||
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
|
||||
private readonly restoreOneResolverFactory: RestoreOneResolverFactory,
|
||||
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
|
||||
private readonly mergeManyResolverFactory: MergeManyResolverFactory,
|
||||
) {
|
||||
this.factoryMap = new Map<string, WorkspaceResolverBuilderFactoryInterface>(
|
||||
[
|
||||
[RESOLVER_METHOD_NAMES.FIND_MANY, this.findManyResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.FIND_ONE, this.findOneResolverFactory],
|
||||
[
|
||||
RESOLVER_METHOD_NAMES.FIND_DUPLICATES,
|
||||
this.findDuplicatesResolverFactory,
|
||||
],
|
||||
[RESOLVER_METHOD_NAMES.GROUP_BY, this.groupByResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.CREATE_ONE, this.createOneResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.CREATE_MANY, this.createManyResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.UPDATE_ONE, this.updateOneResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.UPDATE_MANY, this.updateManyResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.DELETE_ONE, this.deleteOneResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.DELETE_MANY, this.deleteManyResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.DESTROY_ONE, this.destroyOneResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.DESTROY_MANY, this.destroyManyResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.RESTORE_ONE, this.restoreOneResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.RESTORE_MANY, this.restoreManyResolverFactory],
|
||||
[RESOLVER_METHOD_NAMES.MERGE_MANY, this.mergeManyResolverFactory],
|
||||
],
|
||||
);
|
||||
|
||||
this.argsAssertionMap = new Map<string, (args: unknown) => void>([
|
||||
[RESOLVER_METHOD_NAMES.FIND_MANY, assertFindManyArgs],
|
||||
[RESOLVER_METHOD_NAMES.FIND_ONE, assertFindOneArgs],
|
||||
[RESOLVER_METHOD_NAMES.FIND_DUPLICATES, assertFindDuplicatesArgs],
|
||||
[RESOLVER_METHOD_NAMES.GROUP_BY, assertGroupByArgs],
|
||||
[RESOLVER_METHOD_NAMES.CREATE_ONE, assertCreateOneArgs],
|
||||
[RESOLVER_METHOD_NAMES.CREATE_MANY, assertCreateManyArgs],
|
||||
[RESOLVER_METHOD_NAMES.UPDATE_ONE, assertUpdateOneArgs],
|
||||
[RESOLVER_METHOD_NAMES.UPDATE_MANY, assertUpdateManyArgs],
|
||||
[RESOLVER_METHOD_NAMES.DELETE_ONE, assertDeleteOneArgs],
|
||||
[RESOLVER_METHOD_NAMES.DELETE_MANY, assertDeleteManyArgs],
|
||||
[RESOLVER_METHOD_NAMES.DESTROY_ONE, assertDestroyOneArgs],
|
||||
[RESOLVER_METHOD_NAMES.DESTROY_MANY, assertDestroyManyArgs],
|
||||
[RESOLVER_METHOD_NAMES.RESTORE_ONE, assertRestoreOneArgs],
|
||||
[RESOLVER_METHOD_NAMES.RESTORE_MANY, assertRestoreManyArgs],
|
||||
[RESOLVER_METHOD_NAMES.MERGE_MANY, assertMergeManyArgs],
|
||||
]);
|
||||
}
|
||||
|
||||
async getGeneratedWorkspaceResolverNames(
|
||||
workspaceId: string,
|
||||
): Promise<Set<string> | null> {
|
||||
const { graphQLResolverNameMap } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'graphQLResolverNameMap',
|
||||
]);
|
||||
|
||||
return new Set(Object.keys(graphQLResolverNameMap));
|
||||
}
|
||||
|
||||
async execute(
|
||||
req: Request,
|
||||
document: DocumentNode,
|
||||
): Promise<DirectExecutionResult | null> {
|
||||
try {
|
||||
const workspaceId = req.workspace?.id;
|
||||
|
||||
if (!isDefined(workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topLevelFields = graphQLExtractTopLevelFields(
|
||||
document,
|
||||
req.body.operationName,
|
||||
);
|
||||
|
||||
this.checkRootResolverLimitsOrThrow(topLevelFields);
|
||||
|
||||
const fragmentMap = graphQLBuildFragmentMap(document);
|
||||
const variables = req.body.variables ?? {};
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
const { graphQLResolverNameMap } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'graphQLResolverNameMap',
|
||||
]);
|
||||
|
||||
const {
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
} = await this.loadWorkspaceMetadata(workspaceId);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
topLevelFields.map(async (field) => {
|
||||
const entry = graphQLResolverNameMap[field.name.value];
|
||||
const responseKey = field.alias?.value ?? field.name.value;
|
||||
|
||||
const args = extractArgumentsFromAst(field.arguments, variables);
|
||||
|
||||
const graphqlPartialResolveInfo = graphQLBuildPartialResolveInfo(
|
||||
field,
|
||||
fragmentMap,
|
||||
);
|
||||
|
||||
const workspaceSchemaBuilderContext =
|
||||
buildWorkspaceSchemaBuilderContext(
|
||||
entry,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
);
|
||||
|
||||
const result = await this.executeField({
|
||||
entry,
|
||||
args,
|
||||
graphqlPartialResolveInfo,
|
||||
workspaceSchemaBuilderContext,
|
||||
});
|
||||
|
||||
graphQLBackfillNullsFromSelectedFields(
|
||||
result,
|
||||
graphqlFields(graphqlPartialResolveInfo as GraphQLResolveInfo),
|
||||
);
|
||||
|
||||
return { responseKey, result };
|
||||
}),
|
||||
);
|
||||
|
||||
const errors: GraphQLFormattedError[] = [];
|
||||
|
||||
for (const settled of results) {
|
||||
if (settled.status === 'fulfilled') {
|
||||
data[settled.value.responseKey] = settled.value.result;
|
||||
} else {
|
||||
errors.push(this.formatError(settled.reason, req));
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { data, errors };
|
||||
}
|
||||
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { data: null, errors: [this.formatError(error, req)] };
|
||||
}
|
||||
}
|
||||
|
||||
private async executeField({
|
||||
entry,
|
||||
args,
|
||||
graphqlPartialResolveInfo,
|
||||
workspaceSchemaBuilderContext,
|
||||
}: {
|
||||
entry: ResolverNameMapEntry;
|
||||
args: Record<string, unknown>;
|
||||
graphqlPartialResolveInfo: Pick<
|
||||
GraphQLResolveInfo,
|
||||
'fieldNodes' | 'fragments'
|
||||
>;
|
||||
workspaceSchemaBuilderContext: WorkspaceSchemaBuilderContext;
|
||||
}): Promise<unknown> {
|
||||
const factory = this.factoryMap.get(entry.method);
|
||||
const assertFunction = this.argsAssertionMap.get(entry.method);
|
||||
|
||||
if (!isDefined(factory) || !isDefined(assertFunction)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Unknown method: ${entry.method}`,
|
||||
GraphqlDirectExecutionExceptionCode.UNKNOWN_METHOD,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
assertFunction(args);
|
||||
|
||||
const resolver = factory.create(workspaceSchemaBuilderContext);
|
||||
|
||||
return resolver(
|
||||
null,
|
||||
args,
|
||||
null,
|
||||
graphqlPartialResolveInfo as GraphQLResolveInfo,
|
||||
);
|
||||
}
|
||||
|
||||
private formatError(error: any, req: Request): GraphQLFormattedError {
|
||||
try {
|
||||
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
|
||||
} catch (graphqlError) {
|
||||
if (graphqlError instanceof GraphQLError) {
|
||||
const json = graphqlError.toJSON();
|
||||
|
||||
if (json.extensions?.userFriendlyMessage) {
|
||||
const userLocale = req.locale ?? SOURCE_LOCALE;
|
||||
const i18n = this.i18nService.getI18nInstance(userLocale);
|
||||
|
||||
json.extensions.userFriendlyMessage = i18n._(
|
||||
json.extensions.userFriendlyMessage as MessageDescriptor,
|
||||
);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: isDefined(error.message)
|
||||
? error.message
|
||||
: 'Internal server error',
|
||||
extensions: { code: 'INTERNAL_SERVER_ERROR' },
|
||||
};
|
||||
}
|
||||
|
||||
private checkRootResolverLimitsOrThrow(topLevelFields: FieldNode[]): void {
|
||||
const maxRootResolvers = this.twentyConfigService.get(
|
||||
'GRAPHQL_MAX_ROOT_RESOLVERS',
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(maxRootResolvers) &&
|
||||
topLevelFields.length > maxRootResolvers
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Query too complex - Too many root resolvers requested: ${topLevelFields.length} - Maximum allowed root resolvers: ${maxRootResolvers}`,
|
||||
);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const field of topLevelFields) {
|
||||
const name = field.name.value;
|
||||
|
||||
if (seen.has(name)) {
|
||||
throw new UserInputError(`Duplicate root resolver: "${name}"`);
|
||||
}
|
||||
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadWorkspaceMetadata(workspaceId: string) {
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.workspaceFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const { idByNameSingular } = buildObjectIdByNameMaps(
|
||||
flatObjectMetadataMaps,
|
||||
);
|
||||
|
||||
return {
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular: idByNameSingular,
|
||||
};
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum GraphqlDirectExecutionExceptionCode {
|
||||
INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT',
|
||||
UNKNOWN_METHOD = 'UNKNOWN_METHOD',
|
||||
}
|
||||
|
||||
export class GraphqlDirectExecutionException extends CustomException<GraphqlDirectExecutionExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: GraphqlDirectExecutionExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor },
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { type Request } from 'express';
|
||||
import { DocumentNode, parse } from 'graphql';
|
||||
import { type Plugin } from 'graphql-yoga';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { isNull } from '@sniptt/guards';
|
||||
import { type DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service';
|
||||
import { computeSkipWorkspaceSchemaCreation } from 'src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util';
|
||||
import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util';
|
||||
import { hasOnlyGeneratedWorkspaceResolvers } from 'src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util';
|
||||
import { isSubscriptionOperation } from 'src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util';
|
||||
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
export type DirectExecutionPluginConfig = {
|
||||
directExecutionService: DirectExecutionService;
|
||||
featureFlagService: FeatureFlagService;
|
||||
};
|
||||
|
||||
export function useDirectExecution(
|
||||
config: DirectExecutionPluginConfig,
|
||||
): Plugin {
|
||||
return {
|
||||
onRequest: async ({ endResponse, serverContext }) => {
|
||||
const req = (serverContext as unknown as { req: Request }).req;
|
||||
|
||||
if (!req.workspace?.id || !req.body?.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnabled = await config.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED,
|
||||
req.workspace.id,
|
||||
);
|
||||
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedWorkspaceResolverNames =
|
||||
await config.directExecutionService.getGeneratedWorkspaceResolverNames(
|
||||
req.workspace.id,
|
||||
);
|
||||
|
||||
if (!generatedWorkspaceResolverNames) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = req.body.query as string;
|
||||
const operationName = req.body.operationName as string | undefined;
|
||||
|
||||
let document: DocumentNode;
|
||||
try {
|
||||
document = parse(queryString);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!findOperationDefinition(document, operationName) ||
|
||||
isSubscriptionOperation(document, operationName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
queryString,
|
||||
document,
|
||||
operationName,
|
||||
generatedWorkspaceResolverNames,
|
||||
)
|
||||
) {
|
||||
req.skipWorkspaceSchemaCreation = true;
|
||||
}
|
||||
|
||||
if (
|
||||
!hasOnlyGeneratedWorkspaceResolvers(
|
||||
document,
|
||||
operationName,
|
||||
generatedWorkspaceResolverNames,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await config.directExecutionService.execute(req, document);
|
||||
|
||||
if (isNull(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return endResponse(Response.json(result));
|
||||
},
|
||||
};
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
|
||||
|
||||
import {
|
||||
type ResolverNameMapEntry,
|
||||
buildResolverNameMap,
|
||||
} from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util';
|
||||
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
@Injectable()
|
||||
@WorkspaceCache('graphQLResolverNameMap')
|
||||
export class WorkspaceResolverNameMapCacheService extends WorkspaceCacheProvider<
|
||||
Record<string, ResolverNameMapEntry>
|
||||
> {
|
||||
constructor(private readonly workspaceCacheService: WorkspaceCacheService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(
|
||||
workspaceId: string,
|
||||
): Promise<Record<string, ResolverNameMapEntry>> {
|
||||
const { flatObjectMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatObjectMetadataMaps',
|
||||
]);
|
||||
|
||||
return buildResolverNameMap(flatObjectMetadataMaps);
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import { computeSkipWorkspaceSchemaCreation } from 'src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util';
|
||||
|
||||
const GENERATED_RESOLVERS = new Set([
|
||||
'findManyCompanies',
|
||||
'findOneCompany',
|
||||
'createOneCompany',
|
||||
'findManyPeople',
|
||||
'findOnePerson',
|
||||
]);
|
||||
|
||||
describe('computeSkipWorkspaceSchemaCreation', () => {
|
||||
it('should return true when all fields are core resolvers', () => {
|
||||
const query = `
|
||||
query {
|
||||
currentWorkspace { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for multiple core resolver fields', () => {
|
||||
const query = `
|
||||
query {
|
||||
currentWorkspace { id }
|
||||
currentUser { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when all fields are generated workspace resolvers', () => {
|
||||
const query = `
|
||||
query {
|
||||
findManyCompanies { id name }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for mixed queries', () => {
|
||||
const query = `
|
||||
query {
|
||||
findManyCompanies { id }
|
||||
currentWorkspace { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for __schema introspection', () => {
|
||||
const query = `
|
||||
query {
|
||||
__schema { types { name } }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for __type introspection', () => {
|
||||
const query = `
|
||||
query {
|
||||
__type(name: "Company") { name fields { name } }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should not treat __typename as introspection', () => {
|
||||
const query = `
|
||||
query {
|
||||
currentWorkspace { id __typename }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when no operation matches (no fields to check)', () => {
|
||||
const query = `
|
||||
query GetCompanies { findManyCompanies { id } }
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
'NonExistent',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect operationName', () => {
|
||||
const query = `
|
||||
query CoreQuery {
|
||||
currentWorkspace { id }
|
||||
}
|
||||
query WorkspaceQuery {
|
||||
findManyCompanies { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
'CoreQuery',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeSkipWorkspaceSchemaCreation(
|
||||
query,
|
||||
parse(query),
|
||||
'WorkspaceQuery',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util';
|
||||
|
||||
describe('findOperationDefinition', () => {
|
||||
it('should return the single operation when no operationName is given', () => {
|
||||
const document = parse('query { findManyCompanies { id } }');
|
||||
|
||||
const result = findOperationDefinition(document, undefined);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.name).toBeUndefined();
|
||||
expect(result?.operation).toBe('query');
|
||||
});
|
||||
|
||||
it('should throw when multiple operations exist and no operationName is given', () => {
|
||||
const document = parse(`
|
||||
query First { findManyCompanies { id } }
|
||||
query Second { findManyPeople { id } }
|
||||
`);
|
||||
|
||||
expect(() => findOperationDefinition(document, undefined)).toThrow(
|
||||
'Must provide operation name when document contains multiple operations.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the named operation when operationName matches', () => {
|
||||
const document = parse(`
|
||||
query First { findManyCompanies { id } }
|
||||
query Second { findManyPeople { id } }
|
||||
`);
|
||||
|
||||
const result = findOperationDefinition(document, 'Second');
|
||||
|
||||
expect(result?.name?.value).toBe('Second');
|
||||
});
|
||||
|
||||
it('should return undefined when operationName does not match any operation', () => {
|
||||
const document = parse('query MyQuery { findManyCompanies { id } }');
|
||||
|
||||
const result = findOperationDefinition(document, 'NonExistent');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return a mutation operation', () => {
|
||||
const document = parse(
|
||||
'mutation CreateOne { createOnePerson(data: {}) { id } }',
|
||||
);
|
||||
|
||||
const result = findOperationDefinition(document, 'CreateOne');
|
||||
|
||||
expect(result?.operation).toBe('mutation');
|
||||
expect(result?.name?.value).toBe('CreateOne');
|
||||
});
|
||||
|
||||
it('should return undefined for an empty document', () => {
|
||||
const document = parse('type Query { dummy: String }');
|
||||
|
||||
const result = findOperationDefinition(document, undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
|
||||
describe('graphQLExtractTopLevelFields', () => {
|
||||
it('should return top-level fields from a query', () => {
|
||||
const query = `
|
||||
query {
|
||||
findManyCompanies { id name }
|
||||
findManyPeople { id email }
|
||||
}
|
||||
`;
|
||||
|
||||
const fields = graphQLExtractTopLevelFields(parse(query), undefined);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields[0].name.value).toBe('findManyCompanies');
|
||||
expect(fields[1].name.value).toBe('findManyPeople');
|
||||
});
|
||||
|
||||
it('should return top-level fields from a mutation', () => {
|
||||
const query = `
|
||||
mutation {
|
||||
createOnePerson(data: { name: "Test" }) { id }
|
||||
}
|
||||
`;
|
||||
|
||||
const fields = graphQLExtractTopLevelFields(parse(query), undefined);
|
||||
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].name.value).toBe('createOnePerson');
|
||||
});
|
||||
|
||||
it('should select the operation matching operationName', () => {
|
||||
const query = `
|
||||
query GetCompanies {
|
||||
findManyCompanies { id }
|
||||
}
|
||||
query GetPeople {
|
||||
findManyPeople { id }
|
||||
}
|
||||
`;
|
||||
|
||||
const fields = graphQLExtractTopLevelFields(parse(query), 'GetPeople');
|
||||
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].name.value).toBe('findManyPeople');
|
||||
});
|
||||
|
||||
it('should throw when multiple operations exist and operationName is undefined', () => {
|
||||
const query = `
|
||||
query First {
|
||||
findManyCompanies { id }
|
||||
}
|
||||
query Second {
|
||||
findManyPeople { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(() => graphQLExtractTopLevelFields(parse(query), undefined)).toThrow(
|
||||
'Must provide operation name when document contains multiple operations.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array when no operation matches', () => {
|
||||
const query = `
|
||||
query GetCompanies {
|
||||
findManyCompanies { id }
|
||||
}
|
||||
`;
|
||||
|
||||
const fields = graphQLExtractTopLevelFields(parse(query), 'NonExistent');
|
||||
|
||||
expect(fields).toEqual([]);
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import { hasOnlyGeneratedWorkspaceResolvers } from 'src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util';
|
||||
|
||||
const GENERATED_RESOLVERS = new Set([
|
||||
'companies',
|
||||
'company',
|
||||
'createOneCompany',
|
||||
'people',
|
||||
'person',
|
||||
]);
|
||||
|
||||
describe('hasOnlyGeneratedWorkspaceResolvers', () => {
|
||||
it('should return true when all fields are generated workspace resolvers', () => {
|
||||
const query = `
|
||||
query {
|
||||
companies { id name }
|
||||
people { id email }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a single generated workspace resolver', () => {
|
||||
const query = `
|
||||
query {
|
||||
company(filter: { id: { eq: "123" } }) { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when all fields are core resolvers', () => {
|
||||
const query = `
|
||||
query {
|
||||
search { id }
|
||||
getTimelineCalendarEventsFromOpportunityId { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for mixed queries', () => {
|
||||
const query = `
|
||||
query {
|
||||
companies { id }
|
||||
getTimelineCalendarEventsFromOpportunityId { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
undefined,
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when no operation matches (no fields to check)', () => {
|
||||
const query = `
|
||||
query GetCompanies { companies { id } }
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
'NonExistent',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect operationName', () => {
|
||||
const query = `
|
||||
query WorkspaceQuery {
|
||||
companies { id }
|
||||
}
|
||||
query CoreQuery {
|
||||
getTimelineCalendarEventsFromOpportunityId { id }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
'WorkspaceQuery',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
hasOnlyGeneratedWorkspaceResolvers(
|
||||
parse(query),
|
||||
'CoreQuery',
|
||||
GENERATED_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import { isSubscriptionOperation } from 'src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util';
|
||||
|
||||
describe('isSubscriptionOperation', () => {
|
||||
it('should return true for a subscription operation', () => {
|
||||
const query = 'subscription { onCreateCompany { id } }';
|
||||
|
||||
expect(isSubscriptionOperation(parse(query), undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a named subscription matching operationName', () => {
|
||||
const query = `
|
||||
subscription OnCreate { onCreateCompany { id } }
|
||||
query GetAll { findManyCompanies { id } }
|
||||
`;
|
||||
|
||||
expect(isSubscriptionOperation(parse(query), 'OnCreate')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a query operation', () => {
|
||||
const query = 'query { findManyCompanies { id } }';
|
||||
|
||||
expect(isSubscriptionOperation(parse(query), undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a mutation operation', () => {
|
||||
const query = 'mutation { createOnePerson(data: {}) { id } }';
|
||||
|
||||
expect(isSubscriptionOperation(parse(query), undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { isBoolean, isObject } from 'class-validator';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertCreateManyArgs(
|
||||
args: unknown,
|
||||
): asserts args is CreateManyResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
const allowedKeys = new Set(['data', 'upsert']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('data' in args) || !Array.isArray(args.data)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "data" (array)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('upsert' in args && isDefined(args.upsert) && !isBoolean(args.upsert)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "upsert" must be a boolean',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { isBoolean, isObject } from 'class-validator';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertCreateOneArgs(
|
||||
args: unknown,
|
||||
): asserts args is CreateOneResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['data', 'upsert']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('data' in args) || !isObject(args.data)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "data" (object)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('upsert' in args && isDefined(args.upsert) && !isBoolean(args.upsert)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "upsert" must be a boolean',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { isObject } from 'class-validator';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertDeleteManyArgs(
|
||||
args: unknown,
|
||||
): asserts args is DeleteManyResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['filter']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('filter' in args) || !isObject(args.filter)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "filter" (object)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { isObject, isString } from 'class-validator';
|
||||
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type DeleteOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertDeleteOneArgs(
|
||||
args: unknown,
|
||||
): asserts args is DeleteOneResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['id']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "id" (UUID)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { isObject } from 'class-validator';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type DestroyManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertDestroyManyArgs(
|
||||
args: unknown,
|
||||
): asserts args is DestroyManyResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['filter']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('filter' in args) || !isObject(args.filter)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "filter" (object)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { isObject, isString } from 'class-validator';
|
||||
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type DestroyOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertDestroyOneArgs(
|
||||
args: unknown,
|
||||
): asserts args is DestroyOneResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['id']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "id" (UUID)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { isObject } from 'class-validator';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type FindDuplicatesResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertFindDuplicatesArgs(
|
||||
args: unknown,
|
||||
): asserts args is FindDuplicatesResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['data', 'ids']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ('data' in args && isDefined(args.data) && !Array.isArray(args.data)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "data" must be an array',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('ids' in args && isDefined(args.ids) && !Array.isArray(args.ids)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "ids" must be an array',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { isArray, isNumber, isObject, isString } from 'class-validator';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertFindManyArgs(
|
||||
args: unknown,
|
||||
): asserts args is FindManyResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set([
|
||||
'filter',
|
||||
'orderBy',
|
||||
'first',
|
||||
'last',
|
||||
'before',
|
||||
'after',
|
||||
'offset',
|
||||
]);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ('first' in args && isDefined(args.first) && !isNumber(args.first)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "first" must be a number',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('last' in args && isDefined(args.last) && !isNumber(args.last)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "last" must be a number',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('filter' in args && isDefined(args.filter) && !isObject(args.filter)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "filter" must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('orderBy' in args && isDefined(args.orderBy) && !isArray(args.orderBy)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "orderBy" must be an array',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('before' in args && isDefined(args.before) && !isString(args.before)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "before" must be a string',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('after' in args && isDefined(args.after) && !isString(args.after)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "after" must be a string',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('offset' in args && isDefined(args.offset) && !isNumber(args.offset)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "offset" must be a number',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { isObject } from 'class-validator';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type FindOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertFindOneArgs(
|
||||
args: unknown,
|
||||
): asserts args is FindOneResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['filter']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ('filter' in args && isDefined(args.filter) && !isObject(args.filter)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "filter" must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
isArray,
|
||||
isBoolean,
|
||||
isNumber,
|
||||
isObject,
|
||||
isString,
|
||||
} from 'class-validator';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertGroupByArgs(
|
||||
args: unknown,
|
||||
): asserts args is GroupByResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set([
|
||||
'filter',
|
||||
'orderBy',
|
||||
'orderByForRecords',
|
||||
'groupBy',
|
||||
'viewId',
|
||||
'includeRecords',
|
||||
'limit',
|
||||
'offsetForRecords',
|
||||
]);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('groupBy' in args) || !Array.isArray(args.groupBy)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "groupBy" (array)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('filter' in args && isDefined(args.filter) && !isObject(args.filter)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "filter" must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('orderBy' in args && isDefined(args.orderBy) && !isArray(args.orderBy)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "orderBy" must be an array',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
'orderByForRecords' in args &&
|
||||
isDefined(args.orderByForRecords) &&
|
||||
!isArray(args.orderByForRecords)
|
||||
) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "orderByForRecords" must be an array',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('viewId' in args && isDefined(args.viewId) && !isString(args.viewId)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "viewId" must be a string',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
'includeRecords' in args &&
|
||||
isDefined(args.includeRecords) &&
|
||||
!isBoolean(args.includeRecords)
|
||||
) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "includeRecords" must be a boolean',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('limit' in args && isDefined(args.limit) && !isNumber(args.limit)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "limit" must be a number',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
'offsetForRecords' in args &&
|
||||
isDefined(args.offsetForRecords) &&
|
||||
!isNumber(args.offsetForRecords)
|
||||
) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "offsetForRecords" must be a number',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { isBoolean, isNumber, isObject } from 'class-validator';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type MergeManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertMergeManyArgs(
|
||||
args: unknown,
|
||||
): asserts args is MergeManyResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['ids', 'conflictPriorityIndex', 'dryRun']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('ids' in args) || !Array.isArray(args.ids)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "ids" (array)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!('conflictPriorityIndex' in args) ||
|
||||
!isNumber(args.conflictPriorityIndex)
|
||||
) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "conflictPriorityIndex" (number)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if ('dryRun' in args && isDefined(args.dryRun) && !isBoolean(args.dryRun)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: "dryRun" must be a boolean',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { isObject } from 'class-validator';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type RestoreManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertRestoreManyArgs(
|
||||
args: unknown,
|
||||
): asserts args is RestoreManyResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['filter']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('filter' in args) || !isObject(args.filter)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "filter" (object)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { isObject, isString } from 'class-validator';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type RestoreOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
export function assertRestoreOneArgs(
|
||||
args: unknown,
|
||||
): asserts args is RestoreOneResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['id']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "id" (UUID)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { isObject } from 'class-validator';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertUpdateManyArgs(
|
||||
args: unknown,
|
||||
): asserts args is UpdateManyResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['filter', 'data']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('filter' in args) || !isObject(args.filter)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "filter" (object)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (!('data' in args) || !isObject(args.data)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "data" (object)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { isObject, isString } from 'class-validator';
|
||||
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertUpdateOneArgs(
|
||||
args: unknown,
|
||||
): asserts args is UpdateOneResolverArgs {
|
||||
if (!isObject(args)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Invalid argument: it must be an object',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const argKeys = Object.keys(args);
|
||||
|
||||
const allowedKeys = new Set(['id', 'data']);
|
||||
|
||||
for (const key of argKeys) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
`Argument not allowed: ${key}`,
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "id" (UUID)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (!('data' in args) || !isObject(args.data)) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "data" (object)',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories';
|
||||
import { type WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
|
||||
|
||||
export type ResolverNameMapEntry = {
|
||||
objectMetadataUniversalIdentifier: string;
|
||||
method: WorkspaceResolverBuilderMethodNames;
|
||||
operationType: 'query' | 'mutation';
|
||||
};
|
||||
|
||||
export const buildResolverNameMap = (
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
): Record<string, ResolverNameMapEntry> => {
|
||||
const map: Record<string, ResolverNameMapEntry> = {};
|
||||
|
||||
const allMethods = [
|
||||
...workspaceResolverBuilderMethodNames.queries.map((method) => ({
|
||||
method,
|
||||
operationType: 'query' as const,
|
||||
})),
|
||||
...workspaceResolverBuilderMethodNames.mutations.map((method) => ({
|
||||
method,
|
||||
operationType: 'mutation' as const,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const flatObjectMetadata of Object.values(
|
||||
flatObjectMetadataMaps.byUniversalIdentifier,
|
||||
).filter(isDefined)) {
|
||||
for (const { method, operationType } of allMethods) {
|
||||
const resolverName = getResolverName(flatObjectMetadata, method);
|
||||
|
||||
map[resolverName] = {
|
||||
objectMetadataUniversalIdentifier:
|
||||
flatObjectMetadata.universalIdentifier,
|
||||
method,
|
||||
operationType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util';
|
||||
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
export const buildWorkspaceSchemaBuilderContext = (
|
||||
entry: ResolverNameMapEntry,
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
objectIdByNameSingular: Record<string, string>,
|
||||
): WorkspaceSchemaBuilderContext => {
|
||||
const flatObjectMetadata =
|
||||
flatObjectMetadataMaps.byUniversalIdentifier[
|
||||
entry.objectMetadataUniversalIdentifier
|
||||
];
|
||||
|
||||
if (!flatObjectMetadata) {
|
||||
throw new Error(
|
||||
`Object metadata not found for universal identifier: ${entry.objectMetadataUniversalIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
};
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type DocumentNode } from 'graphql';
|
||||
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
|
||||
const INTROSPECTION_PATTERN = /__schema|__type(?!name)/;
|
||||
|
||||
export const computeSkipWorkspaceSchemaCreation = (
|
||||
queryString: string,
|
||||
document: DocumentNode,
|
||||
operationName: string | undefined,
|
||||
generatedWorkspaceResolverNames: Set<string>,
|
||||
): boolean => {
|
||||
if (INTROSPECTION_PATTERN.test(queryString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const topLevelFields = graphQLExtractTopLevelFields(document, operationName);
|
||||
|
||||
const hasCore = topLevelFields.some(
|
||||
(field) => !generatedWorkspaceResolverNames.has(field.name.value),
|
||||
);
|
||||
const hasGenerated = topLevelFields.some((field) =>
|
||||
generatedWorkspaceResolverNames.has(field.name.value),
|
||||
);
|
||||
|
||||
return !(hasCore && hasGenerated);
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type ArgumentNode, valueFromASTUntyped } from 'graphql';
|
||||
import { isDefined, isEmptyObject } from 'twenty-shared/utils';
|
||||
|
||||
// Converts GraphQL AST argument nodes into a plain JS object,
|
||||
// resolving variable references from the variables map.
|
||||
export const extractArgumentsFromAst = (
|
||||
argumentNodes: readonly ArgumentNode[] | undefined,
|
||||
variables: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> => {
|
||||
if (!argumentNodes || argumentNodes.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const arg of argumentNodes) {
|
||||
const value = valueFromASTUntyped(arg.value, variables);
|
||||
if (!isDefined(value) || isEmptyObject(value)) continue;
|
||||
result[arg.name.value] = valueFromASTUntyped(arg.value, variables);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
type DocumentNode,
|
||||
type OperationDefinitionNode,
|
||||
GraphQLError,
|
||||
Kind,
|
||||
} from 'graphql';
|
||||
|
||||
export const findOperationDefinition = (
|
||||
document: DocumentNode,
|
||||
operationName: string | undefined,
|
||||
): OperationDefinitionNode | undefined => {
|
||||
const operations = document.definitions.filter(
|
||||
(definition): definition is OperationDefinitionNode =>
|
||||
definition.kind === Kind.OPERATION_DEFINITION,
|
||||
);
|
||||
|
||||
if (operationName) {
|
||||
return operations.find(
|
||||
(operation) => operation.name?.value === operationName,
|
||||
);
|
||||
}
|
||||
|
||||
if (operations.length > 1) {
|
||||
throw new GraphQLError(
|
||||
'Must provide operation name when document contains multiple operations.',
|
||||
);
|
||||
}
|
||||
|
||||
return operations[0];
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// GraphQL schema execution automatically returns null for nullable fields
|
||||
// that are missing from the resolved object. Since direct execution bypasses
|
||||
// schema resolution, we need to explicitly set requested-but-missing fields
|
||||
// to null so the response shape matches what GraphQL would produce.
|
||||
export const graphQLBackfillNullsFromSelectedFields = (
|
||||
result: unknown,
|
||||
selectedFields: Record<string, object>,
|
||||
): unknown => {
|
||||
if (result === null || result === undefined || typeof result !== 'object') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
return result.map((item) =>
|
||||
graphQLBackfillNullsFromSelectedFields(item, selectedFields),
|
||||
);
|
||||
}
|
||||
|
||||
const record = result as Record<string, unknown>;
|
||||
|
||||
for (const [key, subFields] of Object.entries(selectedFields)) {
|
||||
if (!(key in record)) {
|
||||
record[key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasNestedFields =
|
||||
subFields &&
|
||||
typeof subFields === 'object' &&
|
||||
Object.keys(subFields).length > 0;
|
||||
|
||||
if (!hasNestedFields || record[key] === null || record[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(record[key])) {
|
||||
record[key] = (record[key] as unknown[]).map((item) =>
|
||||
graphQLBackfillNullsFromSelectedFields(
|
||||
item,
|
||||
subFields as Record<string, object>,
|
||||
),
|
||||
);
|
||||
} else if (typeof record[key] === 'object') {
|
||||
graphQLBackfillNullsFromSelectedFields(
|
||||
record[key],
|
||||
subFields as Record<string, object>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { DocumentNode, FragmentDefinitionNode, Kind } from 'graphql';
|
||||
|
||||
export const graphQLBuildFragmentMap = (
|
||||
document: DocumentNode,
|
||||
): Map<string, FragmentDefinitionNode> => {
|
||||
const map = new Map<string, FragmentDefinitionNode>();
|
||||
|
||||
for (const definition of document.definitions) {
|
||||
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
||||
map.set(definition.name.value, definition);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
type FieldNode,
|
||||
type FragmentDefinitionNode,
|
||||
type GraphQLResolveInfo,
|
||||
} from 'graphql';
|
||||
|
||||
export const graphQLBuildPartialResolveInfo = (
|
||||
field: FieldNode,
|
||||
fragmentMap: Map<string, FragmentDefinitionNode>,
|
||||
): Pick<GraphQLResolveInfo, 'fieldNodes' | 'fragments'> => ({
|
||||
fieldNodes: [field],
|
||||
fragments: Object.fromEntries(fragmentMap),
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import {
|
||||
InternalServerError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
export const graphqlDirectExecutionToGraphqlApiExceptionHandler = (
|
||||
error: GraphqlDirectExecutionException,
|
||||
) => {
|
||||
switch (error.code) {
|
||||
case GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT:
|
||||
throw new UserInputError(error);
|
||||
case GraphqlDirectExecutionExceptionCode.UNKNOWN_METHOD:
|
||||
throw new InternalServerError(error);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
}
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type DocumentNode, type FieldNode, Kind } from 'graphql';
|
||||
|
||||
import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util';
|
||||
|
||||
export const graphQLExtractTopLevelFields = (
|
||||
document: DocumentNode,
|
||||
operationName: string | undefined,
|
||||
): FieldNode[] => {
|
||||
const operationDefinition = findOperationDefinition(document, operationName);
|
||||
|
||||
if (!operationDefinition) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return operationDefinition.selectionSet.selections.filter(
|
||||
(selection): selection is FieldNode => selection.kind === Kind.FIELD,
|
||||
);
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type DocumentNode } from 'graphql';
|
||||
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
|
||||
export const hasOnlyGeneratedWorkspaceResolvers = (
|
||||
document: DocumentNode,
|
||||
operationName: string | undefined,
|
||||
generatedWorkspaceResolverNames: Set<string>,
|
||||
): boolean => {
|
||||
const topLevelFields = graphQLExtractTopLevelFields(document, operationName);
|
||||
|
||||
return topLevelFields.every((field) =>
|
||||
generatedWorkspaceResolverNames.has(field.name.value),
|
||||
);
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { type DocumentNode } from 'graphql';
|
||||
|
||||
import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util';
|
||||
|
||||
export const isSubscriptionOperation = (
|
||||
document: DocumentNode,
|
||||
operationName: string | undefined,
|
||||
): boolean => {
|
||||
const operation = findOperationDefinition(document, operationName);
|
||||
|
||||
return operation?.operation === 'subscription';
|
||||
};
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DirectExecutionModule } from 'src/engine/api/graphql/direct-execution/direct-execution.module';
|
||||
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [CoreEngineModule],
|
||||
imports: [CoreEngineModule, DirectExecutionModule],
|
||||
providers: [],
|
||||
exports: [CoreEngineModule],
|
||||
exports: [CoreEngineModule, DirectExecutionModule],
|
||||
})
|
||||
export class GraphQLConfigModule {}
|
||||
|
||||
+12
-2
@@ -18,9 +18,12 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
|
||||
import { DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service';
|
||||
import { useDirectExecution } from 'src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook';
|
||||
import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory';
|
||||
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { useSentryTracing } from 'src/engine/core-modules/exception-handler/hooks/use-sentry-tracing';
|
||||
import { useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers } from 'src/engine/core-modules/graphql/hooks/use-disable-introspection-and-suggestions-for-unauthenticated-users.hook';
|
||||
import { useGraphQLErrorHandlerHook } from 'src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook';
|
||||
@@ -50,12 +53,18 @@ export class GraphQLConfigService
|
||||
private readonly metricsService: MetricsService,
|
||||
private readonly dataloaderService: DataloaderService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly directExecutionService: DirectExecutionService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
createGqlOptions(): YogaDriverConfig {
|
||||
const isDebugMode =
|
||||
this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT;
|
||||
const plugins = [
|
||||
useDirectExecution({
|
||||
directExecutionService: this.directExecutionService,
|
||||
featureFlagService: this.featureFlagService,
|
||||
}),
|
||||
useGraphQLErrorHandlerHook({
|
||||
metricsService: this.metricsService,
|
||||
exceptionHandlerService: this.exceptionHandlerService,
|
||||
@@ -85,10 +94,11 @@ export class GraphQLConfigService
|
||||
resolverSchemaScope: 'core',
|
||||
buildSchemaOptions: {},
|
||||
conditionalSchema: async (context) => {
|
||||
const { workspace, user, application } = context.req;
|
||||
const { workspace, user, application, skipWorkspaceSchemaCreation } =
|
||||
context.req;
|
||||
|
||||
try {
|
||||
if (!isDefined(workspace)) {
|
||||
if (!isDefined(workspace) || skipWorkspaceSchemaCreation) {
|
||||
return new GraphQLSchema({});
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -2,6 +2,8 @@ import { type QueryFailedError } from 'typeorm';
|
||||
|
||||
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { commonQueryRunnerToGraphqlApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util';
|
||||
import { GraphqlDirectExecutionException } from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { graphqlDirectExecutionToGraphqlApiExceptionHandler } from 'src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util';
|
||||
import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util';
|
||||
import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util';
|
||||
@@ -45,6 +47,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
|
||||
return apiKeyGraphqlApiExceptionHandler(error);
|
||||
case error instanceof ThrottlerException:
|
||||
return throttlerToGraphqlApiExceptionHandler(error);
|
||||
case error instanceof GraphqlDirectExecutionException:
|
||||
return graphqlDirectExecutionToGraphqlApiExceptionHandler(error);
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class CreateManyResolverFactory
|
||||
): Resolver<CreateManyResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export class CreateOneResolverFactory
|
||||
): Resolver<CreateOneResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class DeleteManyResolverFactory
|
||||
): Resolver<DeleteManyResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export class DeleteOneResolverFactory
|
||||
): Resolver<DeleteOneResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class DestroyManyResolverFactory
|
||||
): Resolver<DestroyManyResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export class DestroyOneResolverFactory
|
||||
): Resolver<DestroyOneResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class FindDuplicatesResolverFactory
|
||||
): Resolver<FindDuplicatesResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class FindManyResolverFactory
|
||||
): Resolver<FindManyResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export class FindOneResolverFactory
|
||||
): Resolver<FindOneResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
try {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class GroupByResolverFactory
|
||||
): Resolver<GroupByResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export class MergeManyResolverFactory
|
||||
): Resolver<MergeManyResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class RestoreManyResolverFactory
|
||||
): Resolver<RestoreManyResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export class RestoreOneResolverFactory
|
||||
): Resolver<RestoreOneResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class UpdateManyResolverFactory
|
||||
): Resolver<UpdateManyResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export class UpdateOneResolverFactory
|
||||
): Resolver<UpdateOneResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, requestContext, info) => {
|
||||
return async (_source, args, _requestContext, info) => {
|
||||
const selectedFields = graphqlFields(info);
|
||||
|
||||
const resolverContext = createQueryRunnerContext({
|
||||
|
||||
+5
-1
@@ -16,6 +16,10 @@ import { workspaceResolverBuilderFactories } from './factories/factories';
|
||||
WorkspaceResolverFactory,
|
||||
WorkspaceResolverBuilderService,
|
||||
],
|
||||
exports: [WorkspaceResolverFactory, WorkspaceResolverBuilderService],
|
||||
exports: [
|
||||
...workspaceResolverBuilderFactories,
|
||||
WorkspaceResolverFactory,
|
||||
WorkspaceResolverBuilderService,
|
||||
],
|
||||
})
|
||||
export class WorkspaceResolverBuilderModule {}
|
||||
|
||||
Reference in New Issue
Block a user