From e7434bdc39a3e06e6ccca54859db0aa26bb13b02 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:33:56 +0100 Subject: [PATCH] Direct graphql execution (#18759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/generated-metadata/graphql.ts | 1 + .../clients/generated/metadata/schema.graphql | 1 + .../src/clients/generated/metadata/schema.ts | 3 +- packages/twenty-server/@types/express.d.ts | 1 + .../direct-execution.module.ts | 20 + .../direct-execution.service.ts | 357 ++++++++++++++++++ .../graphql-direct-execution.exception.ts | 20 + .../hooks/use-direct-execution.hook.ts | 95 +++++ ...rkspace-resolver-name-map-cache.service.ts | 31 ++ ...kip-workspace-schema-creation.util.spec.ts | 178 +++++++++ .../find-operation-definition.util.spec.ts | 64 ++++ ...phql-extract-top-level-fields.util.spec.ts | 76 ++++ ...-generated-workspace-resolver.util.spec.ts | 121 ++++++ .../is-subscription-operation.util.spec.ts | 32 ++ .../utils/assert-create-many-args.util.ts | 51 +++ .../utils/assert-create-one-args.util.ts | 52 +++ .../utils/assert-delete-many-args.util.ts | 42 +++ .../utils/assert-delete-one-args.util.ts | 44 +++ .../utils/assert-destroy-many-args.util.ts | 42 +++ .../utils/assert-destroy-one-args.util.ts | 44 +++ .../utils/assert-find-duplicates-args.util.ts | 52 +++ .../utils/assert-find-many-args.util.ts | 100 +++++ .../utils/assert-find-one-args.util.ts | 44 +++ .../utils/assert-group-by-args.util.ts | 127 +++++++ .../utils/assert-merge-many-args.util.ts | 63 ++++ .../utils/assert-restore-many-args.util.ts | 42 +++ .../utils/assert-restore-one-args.util.ts | 43 +++ .../utils/assert-update-many-args.util.ts | 50 +++ .../utils/assert-update-one-args.util.ts | 52 +++ .../utils/build-resolver-name-map.util.ts | 47 +++ ...d-workspace-schema-builder-context.util.ts | 30 ++ ...ute-skip-workspace-schema-creation.util.ts | 27 ++ .../utils/extract-arguments-from-ast.util.ts | 23 ++ .../utils/find-operation-definition.util.ts | 30 ++ ...ackfill-nulls-from-selected-fields.util.ts | 52 +++ .../utils/graphql-build-fragment-map.util.ts | 15 + ...graphql-build-partial-resolve-info.util.ts | 13 + ...n-to-graphql-api-exception-handler.util.ts | 24 ++ .../graphql-extract-top-level-fields.util.ts | 18 + ...only-generated-workspace-resolvers.util.ts | 15 + .../utils/is-subscription-operation.util.ts | 12 + .../graphql-config/graphql-config.module.ts | 5 +- .../graphql-config/graphql-config.service.ts | 14 +- ...nner-graphql-api-exception-handler.util.ts | 4 + .../factories/create-many-resolver.factory.ts | 2 +- .../factories/create-one-resolver.factory.ts | 2 +- .../factories/delete-many-resolver.factory.ts | 2 +- .../factories/delete-one-resolver.factory.ts | 2 +- .../destroy-many-resolver.factory.ts | 2 +- .../factories/destroy-one-resolver.factory.ts | 2 +- .../find-duplicates-resolver.factory.ts | 2 +- .../factories/find-many-resolver.factory.ts | 2 +- .../factories/find-one-resolver.factory.ts | 2 +- .../factories/group-by-resolver.factory.ts | 2 +- .../factories/merge-many-resolver.factory.ts | 2 +- .../restore-many-resolver.factory.ts | 2 +- .../factories/restore-one-resolver.factory.ts | 2 +- .../factories/update-many-resolver.factory.ts | 2 +- .../factories/update-one-resolver.factory.ts | 2 +- .../workspace-resolver-builder.module.ts | 6 +- .../workspace-entity-manager.spec.ts | 12 +- .../types/workspace-cache-key.type.ts | 3 + .../workspace-migration-runner.service.ts | 1 + .../twenty-shared/src/types/FeatureFlagKey.ts | 1 + 64 files changed, 2202 insertions(+), 28 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.module.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/find-operation-definition.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/graphql-extract-top-level-fields.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/has-only-generated-workspace-resolver.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/is-subscription-operation.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/find-operation-definition.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6285888cfc1..3a773398379 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1738,6 +1738,7 @@ export enum FeatureFlagKey { IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED', IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED', IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED', + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED = 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED', IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED', IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', diff --git a/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql b/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql index b53643be750..ef35ea7186e 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql +++ b/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql @@ -1548,6 +1548,7 @@ enum FeatureFlagKey { IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED IS_DRAFT_EMAIL_ENABLED IS_RICH_TEXT_V1_MIGRATED + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED IS_CONNECTED_ACCOUNT_MIGRATED } diff --git a/packages/twenty-sdk/src/clients/generated/metadata/schema.ts b/packages/twenty-sdk/src/clients/generated/metadata/schema.ts index a4807811117..874bd5f296e 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/schema.ts +++ b/packages/twenty-sdk/src/clients/generated/metadata/schema.ts @@ -1252,7 +1252,7 @@ export interface FeatureFlag { __typename: 'FeatureFlag' } -export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' +export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' export interface SSOIdentityProvider { id: Scalars['UUID'] @@ -8910,6 +8910,7 @@ export const enumFeatureFlagKey = { IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' as const, IS_DRAFT_EMAIL_ENABLED: 'IS_DRAFT_EMAIL_ENABLED' as const, IS_RICH_TEXT_V1_MIGRATED: 'IS_RICH_TEXT_V1_MIGRATED' as const, + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED: 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' as const, IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const, IS_CONNECTED_ACCOUNT_MIGRATED: 'IS_CONNECTED_ACCOUNT_MIGRATED' as const } diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index 131c1ed75c8..efd06711f31 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -23,5 +23,6 @@ declare module 'express-serve-static-core' { userWorkspaceId?: string; authProvider?: AuthProviderEnum | null; impersonationContext?: RawAuthContext['impersonationContext']; + skipWorkspaceSchemaCreation?: boolean; } } diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.module.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.module.ts new file mode 100644 index 00000000000..1a117c8cfd6 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts new file mode 100644 index 00000000000..0007fdc18bd --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts @@ -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 | null; + errors?: GraphQLFormattedError[]; +}; + +@Injectable() +export class DirectExecutionService { + private readonly factoryMap: Map< + string, + WorkspaceResolverBuilderFactoryInterface + >; + + private readonly argsAssertionMap: Map 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( + [ + [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 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 | null> { + const { graphQLResolverNameMap } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'graphQLResolverNameMap', + ]); + + return new Set(Object.keys(graphQLResolverNameMap)); + } + + async execute( + req: Request, + document: DocumentNode, + ): Promise { + 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 = {}; + + 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; + graphqlPartialResolveInfo: Pick< + GraphQLResolveInfo, + 'fieldNodes' | 'fragments' + >; + workspaceSchemaBuilderContext: WorkspaceSchemaBuilderContext; + }): Promise { + 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(); + + 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, + }; + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception.ts new file mode 100644 index 00000000000..1696f9b1fd0 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception.ts @@ -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 { + constructor( + message: string, + code: GraphqlDirectExecutionExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor }, + ) { + super(message, code, { + userFriendlyMessage, + }); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook.ts new file mode 100644 index 00000000000..b4a719396a0 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook.ts @@ -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)); + }, + }; +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service.ts new file mode 100644 index 00000000000..181fde17f0c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service.ts @@ -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 +> { + constructor(private readonly workspaceCacheService: WorkspaceCacheService) { + super(); + } + + async computeForCache( + workspaceId: string, + ): Promise> { + const { flatObjectMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + ]); + + return buildResolverNameMap(flatObjectMetadataMaps); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts new file mode 100644 index 00000000000..069508e4785 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/find-operation-definition.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/find-operation-definition.util.spec.ts new file mode 100644 index 00000000000..f6618ad8f54 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/find-operation-definition.util.spec.ts @@ -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(); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/graphql-extract-top-level-fields.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/graphql-extract-top-level-fields.util.spec.ts new file mode 100644 index 00000000000..d1b123ae5af --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/graphql-extract-top-level-fields.util.spec.ts @@ -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([]); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/has-only-generated-workspace-resolver.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/has-only-generated-workspace-resolver.util.spec.ts new file mode 100644 index 00000000000..3d8a67e178d --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/has-only-generated-workspace-resolver.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/is-subscription-operation.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/is-subscription-operation.util.spec.ts new file mode 100644 index 00000000000..3b6f4b3ecff --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/is-subscription-operation.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util.ts new file mode 100644 index 00000000000..280d0838d5c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util.ts new file mode 100644 index 00000000000..d5e2e9f3361 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util.ts new file mode 100644 index 00000000000..a28a18d51a0 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util.ts new file mode 100644 index 00000000000..4e70b16e439 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util.ts new file mode 100644 index 00000000000..9b0ddf073a5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util.ts new file mode 100644 index 00000000000..5d15193e054 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util.ts new file mode 100644 index 00000000000..813c6e6c836 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util.ts new file mode 100644 index 00000000000..50beae3ac7d --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util.ts new file mode 100644 index 00000000000..24a2ad19f9d --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts new file mode 100644 index 00000000000..40bff9504e7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util.ts new file mode 100644 index 00000000000..89502cc49da --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util.ts new file mode 100644 index 00000000000..2836f3b9e55 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util.ts new file mode 100644 index 00000000000..95a5dde86a2 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util.ts new file mode 100644 index 00000000000..2f0c7395a22 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util.ts new file mode 100644 index 00000000000..5e8ff81bd7e --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util.ts new file mode 100644 index 00000000000..c59eb9fcf3c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util.ts @@ -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, +): Record => { + const map: Record = {}; + + 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; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts new file mode 100644 index 00000000000..6b66b7c287b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts @@ -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, + flatFieldMetadataMaps: FlatEntityMaps, + objectIdByNameSingular: Record, +): 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, + }; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts new file mode 100644 index 00000000000..2b63cc898b9 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts @@ -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, +): 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); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util.ts new file mode 100644 index 00000000000..018f93b4991 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util.ts @@ -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 | undefined, +): Record => { + if (!argumentNodes || argumentNodes.length === 0) { + return {}; + } + + const result: Record = {}; + + 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; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/find-operation-definition.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/find-operation-definition.util.ts new file mode 100644 index 00000000000..3d9b844f5db --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/find-operation-definition.util.ts @@ -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]; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts new file mode 100644 index 00000000000..a1106973e61 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts @@ -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, +): 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; + + 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, + ), + ); + } else if (typeof record[key] === 'object') { + graphQLBackfillNullsFromSelectedFields( + record[key], + subFields as Record, + ); + } + } + + return result; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util.ts new file mode 100644 index 00000000000..15dd6ddf0e4 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util.ts @@ -0,0 +1,15 @@ +import { DocumentNode, FragmentDefinitionNode, Kind } from 'graphql'; + +export const graphQLBuildFragmentMap = ( + document: DocumentNode, +): Map => { + const map = new Map(); + + for (const definition of document.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + map.set(definition.name.value, definition); + } + } + + return map; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util.ts new file mode 100644 index 00000000000..8fefdbfb60b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util.ts @@ -0,0 +1,13 @@ +import { + type FieldNode, + type FragmentDefinitionNode, + type GraphQLResolveInfo, +} from 'graphql'; + +export const graphQLBuildPartialResolveInfo = ( + field: FieldNode, + fragmentMap: Map, +): Pick => ({ + fieldNodes: [field], + fragments: Object.fromEntries(fragmentMap), +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util.ts new file mode 100644 index 00000000000..7966aa91ad9 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util.ts @@ -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); + } + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util.ts new file mode 100644 index 00000000000..a5b367f70c1 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util.ts @@ -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, + ); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util.ts new file mode 100644 index 00000000000..66112bc8b54 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util.ts @@ -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, +): boolean => { + const topLevelFields = graphQLExtractTopLevelFields(document, operationName); + + return topLevelFields.every((field) => + generatedWorkspaceResolverNames.has(field.name.value), + ); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util.ts new file mode 100644 index 00000000000..deaeb9f95d8 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util.ts @@ -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'; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts index 8b4af6579c0..2d63458e811 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index 6c7715a1836..9df77aad620 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -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({}); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts index 58667e2f7f4..4b25b71d6c5 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts index 9bb43cd973c..4953094cf25 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class CreateManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts index 2412f852b4e..8f7986a7aca 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class CreateOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts index a941564284c..9328cdbe31d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class DeleteManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts index c8a956e8778..66fa9ab811b 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class DeleteOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts index 67b5bbfaca3..bb622bd546f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class DestroyManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts index aa2affcd324..1d090f0a9af 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class DestroyOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts index a833b7beb6b..abd41c9a4c8 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts @@ -31,7 +31,7 @@ export class FindDuplicatesResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts index f8125d87d6f..5e188e1b8f2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class FindManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts index 19e027ea8b0..0dd83863bc1 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class FindOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { try { const selectedFields = graphqlFields(info); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts index be22cdfe131..4de25758be6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts @@ -31,7 +31,7 @@ export class GroupByResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts index da59fe1b028..3d22a30b6d3 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts @@ -30,7 +30,7 @@ export class MergeManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts index 13ac817f89d..e1773de6cd7 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class RestoreManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts index 0706f082de3..2a9518efd0d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class RestoreOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts index e2756a4594b..bb49e72ee45 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class UpdateManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts index 48f273a69e4..06bb7584700 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class UpdateOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts index cd2915d8d77..c869d679652 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts @@ -16,6 +16,10 @@ import { workspaceResolverBuilderFactories } from './factories/factories'; WorkspaceResolverFactory, WorkspaceResolverBuilderService, ], - exports: [WorkspaceResolverFactory, WorkspaceResolverBuilderService], + exports: [ + ...workspaceResolverBuilderFactories, + WorkspaceResolverFactory, + WorkspaceResolverBuilderService, + ], }) export class WorkspaceResolverBuilderModule {} diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 28297aa9efa..448ab698034 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -230,26 +230,24 @@ describe('WorkspaceEntityManager', () => { IS_JSON_FILTER_ENABLED: false, IS_AI_ENABLED: false, IS_APPLICATION_ENABLED: false, - IS_IMAP_SMTP_CALDAV_ENABLED: false, + IS_MARKETPLACE_ENABLED: false, + IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false, IS_PUBLIC_DOMAIN_ENABLED: false, IS_EMAILING_DOMAIN_ENABLED: false, IS_DASHBOARD_V2_ENABLED: false, IS_ATTACHMENT_MIGRATED: false, IS_NOTE_TARGET_MIGRATED: false, IS_TASK_TARGET_MIGRATED: false, - IS_TIMELINE_ACTIVITY_MIGRATED: false, - IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED: false, IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED: false, IS_JUNCTION_RELATIONS_ENABLED: false, IS_COMMAND_MENU_ITEM_ENABLED: false, IS_NAVIGATION_MENU_ITEM_ENABLED: false, - IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: false, - IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false, - IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false, - IS_MARKETPLACE_ENABLED: false, IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED: false, + IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: false, IS_DRAFT_EMAIL_ENABLED: false, IS_RICH_TEXT_V1_MIGRATED: false, + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED: false, + IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false, IS_CONNECTED_ACCOUNT_MIGRATED: false, }, userWorkspaceRoleMap: {}, diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index 02345256aa0..c3457212197 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -4,6 +4,7 @@ import { } from 'twenty-shared/types'; import { type EntityMetadata } from 'typeorm'; +import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util'; import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type'; import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type'; import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; @@ -51,6 +52,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { flatWebhookMaps: 'flat-maps:webhook', flatWorkspaceMemberMaps: 'flat-maps:workspace-member', applicationVariableMaps: 'cache:application-variable', + graphQLResolverNameMap: 'direct-execution:graphql-resolver-name-map', } as const satisfies Record; export type AdditionalCacheDataMaps = { @@ -65,6 +67,7 @@ export type AdditionalCacheDataMaps = { flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps; flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps; applicationVariableMaps: ApplicationVariableCacheMaps; + graphQLResolverNameMap: Record; }; export type WorkspaceCacheDataMap = AllFlatEntityMaps & diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts index aed64f1843d..5d8874791a7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts @@ -97,6 +97,7 @@ export class WorkspaceMigrationRunnerService { 'apiKeyRoleMap', 'ORMEntityMetadatas', 'flatRoleTargetByAgentIdMaps', + 'graphQLResolverNameMap', ]), ); } diff --git a/packages/twenty-shared/src/types/FeatureFlagKey.ts b/packages/twenty-shared/src/types/FeatureFlagKey.ts index d7bd4e01eb2..cbf68e928da 100644 --- a/packages/twenty-shared/src/types/FeatureFlagKey.ts +++ b/packages/twenty-shared/src/types/FeatureFlagKey.ts @@ -19,6 +19,7 @@ export enum FeatureFlagKey { IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED', IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED', IS_RICH_TEXT_V1_MIGRATED = 'IS_RICH_TEXT_V1_MIGRATED', + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED = 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED', IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED', IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED', }