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', }