From a303d9ca1b96abe11149321f80911955058eaeab Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:50:18 +0200 Subject: [PATCH] Gql direct execution - Handle introspection queries (#19219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Context GraphQL introspection queries (__schema, __type) were going through the full Yoga server pipeline, which forces a complete workspace schema build, loading flat metadata maps, building all GraphQL types, wiring all resolver factories, and calling makeExecutableSchema. This is expensive, even though introspection only needs the type structure and zero resolver execution. A new WorkspaceGraphqlSchemaSDLService extracts the SDL computation that was previously embedded inside WorkspaceSchemaFactory. From `direct-execution.service.ts` : - `buildSchema(sdl)` reconstructs a resolver-free GraphQLSchema from the SDL - pure CPU, not cached, not sure it worths it ? - `execute({ schema, document, variableValues })` from graphql-js, introspection is answered entirely by the graphql-js runtime from type metadata, no resolver execution needed #### Nice to do ? - Use new cache service for typeDefs ? ### Renaming bonus: `typeDefs` → `sdl` `typeDefs` is an Apollo/graphql-tools convention. It's the parameter name in `makeExecutableSchema({ typeDefs, resolvers })`, not a native GraphQL spec term. In proper GraphQL semantics, what this service produces is the **SDL** (Schema Definition Language): the official term for the string representation of a schema. `printSchema()` produces it, `buildSchema()` consumes it. ##### Why the distinction matters - **Type definitions** implies partial type declarations (objects, scalars, enums…) - **Schema SDL** conveys a *complete* schema document: all types **plus** the root operation types (`Query`, `Mutation`) which is exactly what `printSchema(schema)` produces --- .../__tests__/workspace.factory.spec.ts | 34 +-- .../api/graphql/core-graphql-api.module.ts | 14 +- .../direct-execution.module.ts | 4 + .../direct-execution.service.ts | 91 ++++++- .../hooks/use-direct-execution.hook.ts | 52 ++-- .../classify-top-level-fields.util.spec.ts | 169 +++++++++++++ ...kip-workspace-schema-creation.util.spec.ts | 178 -------------- ...phql-extract-top-level-fields.util.spec.ts | 35 +++ ...-generated-workspace-resolver.util.spec.ts | 121 --------- .../utils/classify-top-level-fields.util.ts | 35 +++ ...ute-skip-workspace-schema-creation.util.ts | 27 -- .../graphql-extract-top-level-fields.util.ts | 30 ++- ...only-generated-workspace-resolvers.util.ts | 15 -- .../workspace-graphql-schema-sdl.module.ts | 22 ++ .../workspace-graphql-schema-sdl.service.ts | 232 ++++++++++++++++++ .../api/graphql/workspace-schema.factory.ts | 210 ++-------------- .../metrics/types/metrics-keys.type.ts | 1 + 17 files changed, 658 insertions(+), 612 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/classify-top-level-fields.util.spec.ts delete mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts delete 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/classify-top-level-fields.util.ts delete mode 100644 packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts delete 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/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.module.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts diff --git a/packages/twenty-server/src/engine/api/graphql/__tests__/workspace.factory.spec.ts b/packages/twenty-server/src/engine/api/graphql/__tests__/workspace.factory.spec.ts index 328112ec34..a742d181b4 100644 --- a/packages/twenty-server/src/engine/api/graphql/__tests__/workspace.factory.spec.ts +++ b/packages/twenty-server/src/engine/api/graphql/__tests__/workspace.factory.spec.ts @@ -1,16 +1,10 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service'; +import { WorkspaceGraphqlSchemaSDLService } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service'; import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory'; -import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory'; import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; -import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; describe('WorkspaceSchemaFactory', () => { let service: WorkspaceSchemaFactory; @@ -19,40 +13,16 @@ describe('WorkspaceSchemaFactory', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ WorkspaceSchemaFactory, - { - provide: DataSourceService, - useValue: {}, - }, - { - provide: ObjectMetadataService, - useValue: {}, - }, { provide: ScalarsExplorerService, useValue: {}, }, - { - provide: WorkspaceGraphQLSchemaGenerator, - useValue: {}, - }, { provide: WorkspaceResolverFactory, useValue: {}, }, { - provide: WorkspaceCacheStorageService, - useValue: {}, - }, - { - provide: WorkspaceManyOrAllFlatEntityMapsCacheService, - useValue: {}, - }, - { - provide: FeatureFlagService, - useValue: {}, - }, - { - provide: TwentyConfigService, + provide: WorkspaceGraphqlSchemaSDLService, useValue: {}, }, { diff --git a/packages/twenty-server/src/engine/api/graphql/core-graphql-api.module.ts b/packages/twenty-server/src/engine/api/graphql/core-graphql-api.module.ts index 90979cd318..16ba117fcd 100644 --- a/packages/twenty-server/src/engine/api/graphql/core-graphql-api.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/core-graphql-api.module.ts @@ -1,24 +1,16 @@ import { Module } from '@nestjs/common'; import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service'; +import { WorkspaceGraphqlSchemaSDLModule } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.module'; import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module'; -import { WorkspaceSchemaBuilderModule } from 'src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module'; -import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; -import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; -import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; -import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; -import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; +import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { WorkspaceSchemaFactory } from './workspace-schema.factory'; @Module({ imports: [ - DataSourceModule, - WorkspaceSchemaBuilderModule, WorkspaceResolverBuilderModule, - WorkspaceCacheStorageModule, - WorkspaceManyOrAllFlatEntityMapsCacheModule, - FeatureFlagModule, + WorkspaceGraphqlSchemaSDLModule, MetricsModule, ], providers: [WorkspaceSchemaFactory, ScalarsExplorerService], 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 index 1a117c8cfd..faea0181fe 100644 --- 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 @@ -4,6 +4,8 @@ import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.modul 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 { WorkspaceGraphqlSchemaSDLModule } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.module'; +import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.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'; @@ -13,6 +15,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceCacheModule, WorkspaceResolverBuilderModule, + WorkspaceGraphqlSchemaSDLModule, + MetricsModule, ], providers: [DirectExecutionService, WorkspaceResolverNameMapCacheService], exports: [DirectExecutionService], 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 index a05e64d464..63e7588277 100644 --- 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 @@ -4,6 +4,8 @@ import { type MessageDescriptor } from '@lingui/core'; import { type Request } from 'express'; import { GraphQLError, + buildSchema, + execute, type DocumentNode, type FieldNode, type GraphQLFormattedError, @@ -40,6 +42,7 @@ import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution 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 { graphQLFormatResultFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-format-result-from-selected-fields.util'; +import { WorkspaceGraphqlSchemaSDLService } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service'; import { ResolverOutput } from 'src/engine/api/graphql/workspace-query-runner/interfaces/resolver-output'; 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'; @@ -62,6 +65,8 @@ import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/gr 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 { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; +import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; 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'; @@ -84,8 +89,10 @@ export class DirectExecutionService { constructor( private readonly workspaceFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceGraphqlSchemaSDLService: WorkspaceGraphqlSchemaSDLService, private readonly twentyConfigService: TwentyConfigService, private readonly i18nService: I18nService, + private readonly metricsService: MetricsService, private readonly findManyResolverFactory: FindManyResolverFactory, private readonly findOneResolverFactory: FindOneResolverFactory, private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory, @@ -144,7 +151,7 @@ export class DirectExecutionService { ]); } - async getGeneratedWorkspaceResolverNames( + async getWorkspaceResolverNames( workspaceId: string, ): Promise | null> { const { graphQLResolverNameMap } = @@ -158,6 +165,25 @@ export class DirectExecutionService { async execute( req: Request, document: DocumentNode, + hasIntrospectionFields: boolean, + hasWorkspaceFields: boolean, + ): Promise { + const [introspectionResult, workspaceResult] = await Promise.all([ + hasIntrospectionFields + ? this.executeIntrospectionQuery(req, document) + : null, + hasWorkspaceFields ? this.executeWorkspaceQuery(req, document) : null, + ]); + + return this.mergeDirectExecutionResults( + introspectionResult, + workspaceResult, + ); + } + + private async executeWorkspaceQuery( + req: Request, + document: DocumentNode, ): Promise { try { const workspaceId = req.workspace?.id; @@ -255,6 +281,69 @@ export class DirectExecutionService { } } + private async executeIntrospectionQuery( + req: Request, + document: DocumentNode, + ): Promise { + try { + if (!isDefined(req.workspace)) { + return null; + } + + const schemaSDLResult = + await this.workspaceGraphqlSchemaSDLService.getOrComputeSchemaSDL( + req.workspace, + req.application?.id ?? undefined, + ); + + if (!isDefined(schemaSDLResult)) { + return null; + } + + const schema = buildSchema(schemaSDLResult.sdl); + const result = await execute({ + schema, + document, + operationName: req.body?.operationName as string | undefined, + variableValues: (req.body?.variables as Record) ?? {}, + }); + + await this.metricsService.incrementCounter({ + key: MetricsKeys.GraphqlIntrospectionDirectExecution, + shouldStoreInCache: false, + }); + + return { + data: result.data as Record | undefined, + errors: result.errors?.map((error) => error.toJSON()), + }; + } catch (error) { + return { errors: [this.formatError(error, req)] }; + } + } + + private mergeDirectExecutionResults( + introspectionResult: DirectExecutionResult | null, + workspaceResult: DirectExecutionResult | null, + ): DirectExecutionResult | null { + if (!introspectionResult && !workspaceResult) { + return null; + } + + const errors = [ + ...(introspectionResult?.errors ?? []), + ...(workspaceResult?.errors ?? []), + ]; + + return { + data: { + ...introspectionResult?.data, + ...workspaceResult?.data, + }, + ...(errors.length > 0 ? { errors } : {}), + }; + } + private async executeField({ entry, args, 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 index d5eafd6826..651b779f3c 100644 --- 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 @@ -5,9 +5,8 @@ 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 { classifyTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/classify-top-level-fields.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'; @@ -37,15 +36,6 @@ export function useDirectExecution( 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; @@ -63,28 +53,32 @@ export function useDirectExecution( return; } - if ( - computeSkipWorkspaceSchemaCreation( - queryString, - document, - operationName, - generatedWorkspaceResolverNames, - ) - ) { - req.skipWorkspaceSchemaCreation = true; - } + const workspaceResolverNames = + await config.directExecutionService.getWorkspaceResolverNames( + req.workspace.id, + ); - if ( - !hasOnlyGeneratedWorkspaceResolvers( - document, - operationName, - generatedWorkspaceResolverNames, - ) - ) { + if (!workspaceResolverNames) { return; } - const result = await config.directExecutionService.execute(req, document); + const { hasIntrospectionFields, hasWorkspaceFields, hasCoreFields } = + classifyTopLevelFields(document, operationName, workspaceResolverNames); + + if (!hasCoreFields) { + req.skipWorkspaceSchemaCreation = true; + } + + if (hasCoreFields) { + return; + } + + const result = await config.directExecutionService.execute( + req, + document, + hasIntrospectionFields, + hasWorkspaceFields, + ); if (isNull(result)) { return; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/classify-top-level-fields.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/classify-top-level-fields.util.spec.ts new file mode 100644 index 0000000000..b87024425c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/classify-top-level-fields.util.spec.ts @@ -0,0 +1,169 @@ +import { parse } from 'graphql'; + +import { classifyTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/classify-top-level-fields.util'; + +const WORKSPACE_RESOLVERS = new Set([ + 'findManyCompanies', + 'findOneCompany', + 'createOneCompany', + 'findManyPeople', + 'findOnePerson', +]); + +describe('classifyTopLevelFields', () => { + it('should classify a pure introspection query', () => { + const query = ` + query { + __schema { types { name } } + __type(name: "Company") { name } + } + `; + + expect( + classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS), + ).toEqual({ + hasIntrospectionFields: true, + hasWorkspaceFields: false, + hasCoreFields: false, + }); + }); + + it('should classify a pure workspace query', () => { + const query = ` + query { + findManyCompanies { id name } + findOnePerson { id } + } + `; + + expect( + classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS), + ).toEqual({ + hasIntrospectionFields: false, + hasWorkspaceFields: true, + hasCoreFields: false, + }); + }); + + it('should classify a pure core query', () => { + const query = ` + query { + currentWorkspace { id } + currentUser { id } + } + `; + + expect( + classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS), + ).toEqual({ + hasIntrospectionFields: false, + hasWorkspaceFields: false, + hasCoreFields: true, + }); + }); + + it('should classify a mixed introspection + workspace query', () => { + const query = ` + query { + __schema { types { name } } + findManyCompanies { id } + } + `; + + expect( + classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS), + ).toEqual({ + hasIntrospectionFields: true, + hasWorkspaceFields: true, + hasCoreFields: false, + }); + }); + + it('should classify a mixed workspace + core query', () => { + const query = ` + query { + findManyCompanies { id } + currentWorkspace { id } + } + `; + + expect( + classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS), + ).toEqual({ + hasIntrospectionFields: false, + hasWorkspaceFields: true, + hasCoreFields: true, + }); + }); + + it('should not classify __typename as introspection', () => { + const query = ` + query { + currentWorkspace { id __typename } + } + `; + + expect( + classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS), + ).toEqual({ + hasIntrospectionFields: false, + hasWorkspaceFields: false, + hasCoreFields: true, + }); + }); + + it('should expand fragment spreads when classifying', () => { + const query = ` + query { + __schema { types { name } } + ...WorkspaceFragment + } + fragment WorkspaceFragment on Query { + findManyCompanies { id } + } + `; + + expect( + classifyTopLevelFields(parse(query), undefined, WORKSPACE_RESOLVERS), + ).toEqual({ + hasIntrospectionFields: true, + hasWorkspaceFields: true, + hasCoreFields: false, + }); + }); + + it('should respect operationName', () => { + const query = ` + query IntrospectionQuery { + __schema { types { name } } + } + query WorkspaceQuery { + findManyCompanies { id } + } + `; + + expect( + classifyTopLevelFields( + parse(query), + 'IntrospectionQuery', + WORKSPACE_RESOLVERS, + ), + ).toEqual({ + hasIntrospectionFields: true, + hasWorkspaceFields: false, + hasCoreFields: false, + }); + + expect( + classifyTopLevelFields( + parse(query), + 'WorkspaceQuery', + WORKSPACE_RESOLVERS, + ), + ).toEqual({ + hasIntrospectionFields: false, + hasWorkspaceFields: true, + hasCoreFields: false, + }); + }); +}); 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 deleted file mode 100644 index 069508e478..0000000000 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -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__/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 index d1b123ae5a..6d30eb2d04 100644 --- 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 @@ -73,4 +73,39 @@ describe('graphQLExtractTopLevelFields', () => { expect(fields).toEqual([]); }); + + it('should expand fragment spreads at the top level', () => { + const query = ` + query { + __schema { types { name } } + ...DataFragment + } + fragment DataFragment on Query { + companies { id } + } + `; + + const fields = graphQLExtractTopLevelFields(parse(query), undefined); + + expect(fields).toHaveLength(2); + expect(fields[0].name.value).toBe('__schema'); + expect(fields[1].name.value).toBe('companies'); + }); + + it('should expand inline fragments at the top level', () => { + const query = ` + query { + __schema { types { name } } + ... on Query { + companies { id } + } + } + `; + + const fields = graphQLExtractTopLevelFields(parse(query), undefined); + + expect(fields).toHaveLength(2); + expect(fields[0].name.value).toBe('__schema'); + expect(fields[1].name.value).toBe('companies'); + }); }); 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 deleted file mode 100644 index 3d8a67e178..0000000000 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/has-only-generated-workspace-resolver.util.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -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/classify-top-level-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/classify-top-level-fields.util.ts new file mode 100644 index 0000000000..c932328592 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/classify-top-level-fields.util.ts @@ -0,0 +1,35 @@ +import { type DocumentNode } from 'graphql'; + +import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util'; + +const INTROSPECTION_FIELD_NAMES = new Set(['__schema', '__type']); + +type TopLevelFieldsClassification = { + hasIntrospectionFields: boolean; + hasWorkspaceFields: boolean; + hasCoreFields: boolean; +}; + +export const classifyTopLevelFields = ( + document: DocumentNode, + operationName: string | undefined, + workspaceResolverNames: Set, +): TopLevelFieldsClassification => { + const topLevelFields = graphQLExtractTopLevelFields(document, operationName); + + let hasIntrospectionFields = false; + let hasWorkspaceFields = false; + let hasCoreFields = false; + + for (const field of topLevelFields) { + if (INTROSPECTION_FIELD_NAMES.has(field.name.value)) { + hasIntrospectionFields = true; + } else if (workspaceResolverNames.has(field.name.value)) { + hasWorkspaceFields = true; + } else { + hasCoreFields = true; + } + } + + return { hasIntrospectionFields, hasWorkspaceFields, hasCoreFields }; +}; 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 deleted file mode 100644 index 2b63cc898b..0000000000 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts +++ /dev/null @@ -1,27 +0,0 @@ -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/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 index a5b367f70c..6d0a7c80f3 100644 --- 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 @@ -1,6 +1,7 @@ import { type DocumentNode, type FieldNode, Kind } from 'graphql'; import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util'; +import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util'; export const graphQLExtractTopLevelFields = ( document: DocumentNode, @@ -12,7 +13,30 @@ export const graphQLExtractTopLevelFields = ( return []; } - return operationDefinition.selectionSet.selections.filter( - (selection): selection is FieldNode => selection.kind === Kind.FIELD, - ); + const fragmentMap = graphQLBuildFragmentMap(document); + const fields: FieldNode[] = []; + + for (const selection of operationDefinition.selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + fields.push(selection); + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragment = fragmentMap.get(selection.name.value); + + if (fragment) { + for (const fragmentSelection of fragment.selectionSet.selections) { + if (fragmentSelection.kind === Kind.FIELD) { + fields.push(fragmentSelection); + } + } + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + for (const inlineSelection of selection.selectionSet.selections) { + if (inlineSelection.kind === Kind.FIELD) { + fields.push(inlineSelection); + } + } + } + } + + return fields; }; 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 deleted file mode 100644 index 66112bc8b5..0000000000 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.module.ts new file mode 100644 index 0000000000..bdcd1a3b26 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; + +import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service'; +import { WorkspaceSchemaBuilderModule } from 'src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module'; +import { WorkspaceGraphqlSchemaSDLService } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; + +@Module({ + imports: [ + WorkspaceSchemaBuilderModule, + WorkspaceCacheStorageModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, + FeatureFlagModule, + DataSourceModule, + ], + providers: [WorkspaceGraphqlSchemaSDLService, ScalarsExplorerService], + exports: [WorkspaceGraphqlSchemaSDLService], +}) +export class WorkspaceGraphqlSchemaSDLModule {} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts new file mode 100644 index 0000000000..c6482ef8fc --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts @@ -0,0 +1,232 @@ +import { Injectable } from '@nestjs/common'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { printSchema } from 'graphql'; +import { FeatureFlagKey } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service'; +import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { + FlatEntityMapsException, + FlatEntityMapsExceptionCode, +} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; + +export type WorkspaceGraphqlSchemaSDLResult = { + sdl: string; + usedScalarNames: string[]; + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; +}; + +@Injectable() +export class WorkspaceGraphqlSchemaSDLService { + constructor( + private readonly scalarsExplorerService: ScalarsExplorerService, + private readonly workspaceGraphQLSchemaGenerator: WorkspaceGraphQLSchemaGenerator, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly featureFlagService: FeatureFlagService, + private readonly dataSourceService: DataSourceService, + ) {} + + async getOrComputeSchemaSDL( + workspace: FlatWorkspace, + applicationId?: string, + ): Promise { + const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_DATASOURCE_MIGRATED, + workspace.id, + ); + + const hasSchema = isDataSourceMigrated + ? isNonEmptyString(workspace.databaseSchema) + : ( + await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId( + workspace.id, + ) + ).length > 0; + + if (!hasSchema) { + return null; + } + + const { + flatObjectMetadataMaps: allFlatObjectMetadataMaps, + flatFieldMetadataMaps: allFlatFieldMetadataMaps, + flatIndexMaps: allFlatIndexMaps, + flatApplicationMaps, + } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId: workspace.id, + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatIndexMaps', + 'flatApplicationMaps', + ], + }, + ); + + if (!isDefined(allFlatObjectMetadataMaps)) { + throw new FlatEntityMapsException( + 'Object metadata collection not found', + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + if (!isDefined(allFlatFieldMetadataMaps)) { + throw new FlatEntityMapsException( + 'Field metadata collection not found', + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + let flatObjectMetadataMaps = allFlatObjectMetadataMaps; + let flatFieldMetadataMaps = allFlatFieldMetadataMaps; + let flatIndexMaps = allFlatIndexMaps; + + if (isDefined(applicationId)) { + const twentyStandardApplicationId = + flatApplicationMaps?.idByUniversalIdentifier[ + TWENTY_STANDARD_APPLICATION.universalIdentifier + ]; + + const applicationIds = isDefined(twentyStandardApplicationId) + ? [twentyStandardApplicationId, applicationId] + : [applicationId]; + + flatObjectMetadataMaps = this.filterFlatEntityMapsByApplicationIds( + allFlatObjectMetadataMaps, + applicationIds, + ); + flatFieldMetadataMaps = this.filterFlatEntityMapsByApplicationIds( + allFlatFieldMetadataMaps, + applicationIds, + ); + + flatObjectMetadataMaps = + this.reconcileObjectFieldIdsWithFilteredFieldMaps( + flatObjectMetadataMaps, + flatFieldMetadataMaps, + ); + + if (isDefined(allFlatIndexMaps)) { + flatIndexMaps = this.filterFlatEntityMapsByApplicationIds( + allFlatIndexMaps, + applicationIds, + ); + } + } + + let metadataVersion = + await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); + + if (!isDefined(metadataVersion)) { + metadataVersion = isDefined(workspace.metadataVersion) + ? workspace.metadataVersion + : 0; + await this.workspaceCacheStorageService.setMetadataVersion( + workspace.id, + metadataVersion, + ); + } + + let sdl = await this.workspaceCacheStorageService.getGraphQLTypeDefs( + workspace.id, + metadataVersion, + applicationId, + ); + let usedScalarNames = + await this.workspaceCacheStorageService.getGraphQLUsedScalarNames( + workspace.id, + metadataVersion, + applicationId, + ); + + if (!sdl || !usedScalarNames) { + const autoGeneratedSchema = + await this.workspaceGraphQLSchemaGenerator.generateSchema({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatIndexMaps, + }); + + usedScalarNames = + this.scalarsExplorerService.getUsedScalarNames(autoGeneratedSchema); + sdl = printSchema(autoGeneratedSchema); + + await this.workspaceCacheStorageService.setGraphQLTypeDefs( + workspace.id, + metadataVersion, + sdl, + applicationId, + ); + await this.workspaceCacheStorageService.setGraphQLUsedScalarNames( + workspace.id, + metadataVersion, + usedScalarNames, + applicationId, + ); + } + + return { + sdl, + usedScalarNames, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }; + } + + private reconcileObjectFieldIdsWithFilteredFieldMaps( + flatObjectMetadataMaps: FlatEntityMaps, + flatFieldMetadataMaps: FlatEntityMaps, + ): FlatEntityMaps { + const filteredFieldIds = new Set( + Object.keys(flatFieldMetadataMaps.universalIdentifierById), + ); + + const reconciledByUniversalIdentifier: Partial< + Record + > = {}; + + for (const [universalId, object] of Object.entries( + flatObjectMetadataMaps.byUniversalIdentifier, + )) { + if (!isDefined(object)) continue; + + reconciledByUniversalIdentifier[universalId] = { + ...object, + fieldIds: object.fieldIds.filter((id) => filteredFieldIds.has(id)), + }; + } + + return { + ...flatObjectMetadataMaps, + byUniversalIdentifier: reconciledByUniversalIdentifier, + }; + } + + private filterFlatEntityMapsByApplicationIds< + T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata, + >( + flatEntityMaps: FlatEntityMaps, + applicationIds: string[], + ): FlatEntityMaps { + return getSubFlatEntityMapsByApplicationIdsOrThrow({ + applicationIds, + flatEntityMaps, + }); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index 5a26229e18..84a110a4da 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -1,45 +1,25 @@ import { Injectable } from '@nestjs/common'; import { makeExecutableSchema } from '@graphql-tools/schema'; -import { isNonEmptyString } from '@sniptt/guards'; -import { GraphQLSchema, printSchema } from 'graphql'; +import { GraphQLSchema } from 'graphql'; import { gql } from 'graphql-tag'; -import { FeatureFlagKey } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service'; +import { WorkspaceGraphqlSchemaSDLService } from 'src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service'; import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories'; import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory'; -import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; -import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; -import { - FlatEntityMapsException, - FlatEntityMapsExceptionCode, -} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; -import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; -import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; -import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; -import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; @Injectable() export class WorkspaceSchemaFactory { constructor( private readonly scalarsExplorerService: ScalarsExplorerService, - private readonly workspaceGraphQLSchemaGenerator: WorkspaceGraphQLSchemaGenerator, private readonly workspaceResolverFactory: WorkspaceResolverFactory, - private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, - private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, - private readonly featureFlagService: FeatureFlagService, - private readonly dataSourceService: DataSourceService, + private readonly workspaceGraphqlSchemaSDLService: WorkspaceGraphqlSchemaSDLService, private readonly metricsService: MetricsService, ) {} @@ -47,146 +27,27 @@ export class WorkspaceSchemaFactory { workspace: FlatWorkspace, applicationId?: string, ): Promise { - const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_DATASOURCE_MIGRATED, - workspace.id, - ); + const schemaSDLResult = + await this.workspaceGraphqlSchemaSDLService.getOrComputeSchemaSDL( + workspace, + applicationId, + ); - const hasSchema = isDataSourceMigrated - ? isNonEmptyString(workspace.databaseSchema) - : ( - await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId( - workspace.id, - ) - ).length > 0; - - if (!hasSchema) { + if (!isDefined(schemaSDLResult)) { return new GraphQLSchema({}); } const { - flatObjectMetadataMaps: allFlatObjectMetadataMaps, - flatFieldMetadataMaps: allFlatFieldMetadataMaps, - flatIndexMaps: allFlatIndexMaps, - flatApplicationMaps, - } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId: workspace.id, - flatMapsKeys: [ - 'flatObjectMetadataMaps', - 'flatFieldMetadataMaps', - 'flatIndexMaps', - 'flatApplicationMaps', - ], - }, - ); - - if (!isDefined(allFlatObjectMetadataMaps)) { - throw new FlatEntityMapsException( - 'Object metadata collection not found', - FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, - ); - } - - if (!isDefined(allFlatFieldMetadataMaps)) { - throw new FlatEntityMapsException( - 'Field metadata collection not found', - FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, - ); - } - - let flatObjectMetadataMaps = allFlatObjectMetadataMaps; - let flatFieldMetadataMaps = allFlatFieldMetadataMaps; - let flatIndexMaps = allFlatIndexMaps; - - if (isDefined(applicationId)) { - const twentyStandardApplicationId = - flatApplicationMaps?.idByUniversalIdentifier[ - TWENTY_STANDARD_APPLICATION.universalIdentifier - ]; - - const applicationIds = isDefined(twentyStandardApplicationId) - ? [twentyStandardApplicationId, applicationId] - : [applicationId]; - - flatObjectMetadataMaps = this.filterFlatEntityMapsByApplicationIds( - allFlatObjectMetadataMaps, - applicationIds, - ); - flatFieldMetadataMaps = this.filterFlatEntityMapsByApplicationIds( - allFlatFieldMetadataMaps, - applicationIds, - ); - - flatObjectMetadataMaps = - this.reconcileObjectFieldIdsWithFilteredFieldMaps( - flatObjectMetadataMaps, - flatFieldMetadataMaps, - ); - - if (isDefined(allFlatIndexMaps)) { - flatIndexMaps = this.filterFlatEntityMapsByApplicationIds( - allFlatIndexMaps, - applicationIds, - ); - } - } - - let metadataVersion = - await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); - - if (!isDefined(metadataVersion)) { - metadataVersion = isDefined(workspace.metadataVersion) - ? workspace.metadataVersion - : 0; - await this.workspaceCacheStorageService.setMetadataVersion( - workspace.id, - metadataVersion, - ); - } + sdl, + usedScalarNames, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + } = schemaSDLResult; const { idByNameSingular } = buildObjectIdByNameMaps( flatObjectMetadataMaps, ); - let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs( - workspace.id, - metadataVersion, - applicationId, - ); - let usedScalarNames = - await this.workspaceCacheStorageService.getGraphQLUsedScalarNames( - workspace.id, - metadataVersion, - applicationId, - ); - - if (!typeDefs || !usedScalarNames) { - const autoGeneratedSchema = - await this.workspaceGraphQLSchemaGenerator.generateSchema({ - flatObjectMetadataMaps, - flatFieldMetadataMaps, - flatIndexMaps, - }); - - usedScalarNames = - this.scalarsExplorerService.getUsedScalarNames(autoGeneratedSchema); - typeDefs = printSchema(autoGeneratedSchema); - - await this.workspaceCacheStorageService.setGraphQLTypeDefs( - workspace.id, - metadataVersion, - typeDefs, - applicationId, - ); - await this.workspaceCacheStorageService.setGraphQLUsedScalarNames( - workspace.id, - metadataVersion, - usedScalarNames, - applicationId, - ); - } - const autoGeneratedResolvers = await this.workspaceResolverFactory.create( flatObjectMetadataMaps, flatFieldMetadataMaps, @@ -198,7 +59,7 @@ export class WorkspaceSchemaFactory { const executableSchema = makeExecutableSchema({ typeDefs: gql` - ${typeDefs} + ${sdl} `, resolvers: { ...scalarsResolvers, @@ -213,45 +74,4 @@ export class WorkspaceSchemaFactory { return executableSchema; } - - private reconcileObjectFieldIdsWithFilteredFieldMaps( - flatObjectMetadataMaps: FlatEntityMaps, - flatFieldMetadataMaps: FlatEntityMaps, - ): FlatEntityMaps { - const filteredFieldIds = new Set( - Object.keys(flatFieldMetadataMaps.universalIdentifierById), - ); - - const reconciledByUniversalIdentifier: Partial< - Record - > = {}; - - for (const [universalId, object] of Object.entries( - flatObjectMetadataMaps.byUniversalIdentifier, - )) { - if (!isDefined(object)) continue; - - reconciledByUniversalIdentifier[universalId] = { - ...object, - fieldIds: object.fieldIds.filter((id) => filteredFieldIds.has(id)), - }; - } - - return { - ...flatObjectMetadataMaps, - byUniversalIdentifier: reconciledByUniversalIdentifier, - }; - } - - private filterFlatEntityMapsByApplicationIds< - T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata, - >( - flatEntityMaps: FlatEntityMaps, - applicationIds: string[], - ): FlatEntityMaps { - return getSubFlatEntityMapsByApplicationIdsOrThrow({ - applicationIds, - flatEntityMaps, - }); - } } diff --git a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts index 15b9c66d2c..4b51753e86 100644 --- a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts +++ b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts @@ -35,4 +35,5 @@ export enum MetricsKeys { JobFailed = 'job/failed', JobWaiting = 'job/waiting', GraphqlSchemaBuild = 'graphql/schema-build', + GraphqlIntrospectionDirectExecution = 'graphql-introspection/direct-execution', }