diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index e1e02f7971..9dad02925d 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -3378,6 +3378,12 @@ export type ObjectPermissionInput = { objectMetadataId: Scalars['UUID']; }; +export type ObjectRecordCount = { + __typename?: 'ObjectRecordCount'; + objectNamePlural: Scalars['String']; + totalCount: Scalars['Int']; +}; + export type ObjectRecordEvent = { __typename?: 'ObjectRecordEvent'; action: DatabaseEventAction; @@ -3751,6 +3757,7 @@ export type Query = { navigationMenuItem?: Maybe; navigationMenuItems: Array; object: Object; + objectRecordCounts: Array; objects: ObjectConnection; pieChartData: PieChartDataOutput; skill?: Maybe; @@ -5956,6 +5963,11 @@ export type ObjectMetadataItemsQueryVariables = Exact<{ [key: string]: never; }> export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'ObjectEdge', node: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; +export type ObjectRecordCountsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ObjectRecordCountsQuery = { __typename?: 'Query', objectRecordCounts: Array<{ __typename?: 'ObjectRecordCount', objectNamePlural: string, totalCount: number }> }; + export type SkipBookOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -11442,6 +11454,41 @@ export function useObjectMetadataItemsLazyQuery(baseOptions?: Apollo.LazyQueryHo export type ObjectMetadataItemsQueryHookResult = ReturnType; export type ObjectMetadataItemsLazyQueryHookResult = ReturnType; export type ObjectMetadataItemsQueryResult = Apollo.QueryResult; +export const ObjectRecordCountsDocument = gql` + query ObjectRecordCounts { + objectRecordCounts { + objectNamePlural + totalCount + } +} + `; + +/** + * __useObjectRecordCountsQuery__ + * + * To run a query within a React component, call `useObjectRecordCountsQuery` and pass it any options that fit your needs. + * When your component renders, `useObjectRecordCountsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useObjectRecordCountsQuery({ + * variables: { + * }, + * }); + */ +export function useObjectRecordCountsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ObjectRecordCountsDocument, options); + } +export function useObjectRecordCountsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ObjectRecordCountsDocument, options); + } +export type ObjectRecordCountsQueryHookResult = ReturnType; +export type ObjectRecordCountsLazyQueryHookResult = ReturnType; +export type ObjectRecordCountsQueryResult = Apollo.QueryResult; export const SkipBookOnboardingStepDocument = gql` mutation SkipBookOnboardingStep { skipBookOnboardingStep { diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts index 01abc1e07f..07e7553966 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts @@ -19,3 +19,12 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` } } `; + +export const OBJECT_RECORD_COUNTS = gql` + query ObjectRecordCounts { + objectRecordCounts { + objectNamePlural + totalCount + } + } +`; diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedGetTotalCount.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedGetTotalCount.ts index 5303f6783f..de810a9b50 100644 --- a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedGetTotalCount.ts +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedGetTotalCount.ts @@ -1,48 +1,27 @@ import { useQuery } from '@apollo/client'; +import { useMemo } from 'react'; -import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; -import { type RecordGqlOperationSignature } from 'twenty-shared/types'; -import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery'; -import { type CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult'; +import { OBJECT_RECORD_COUNTS } from '@/object-metadata/graphql/queries'; -export const useCombinedGetTotalCount = ({ - objectMetadataItems, - skip = false, -}: { - objectMetadataItems: ObjectMetadataItem[]; - skip?: boolean; -}) => { - const operationSignatures = objectMetadataItems.map( - (objectMetadataItem) => - ({ - objectNameSingular: objectMetadataItem.nameSingular, - variables: {}, - fields: { - id: true, - }, - }) satisfies RecordGqlOperationSignature, - ); +type ObjectRecordCountsResult = { + objectRecordCounts: { + objectNamePlural: string; + totalCount: number; + }[]; +}; - const apolloCoreClient = useApolloCoreClient(); - const findManyQuery = useGenerateCombinedFindManyRecordsQuery({ - operationSignatures, - }); +export const useCombinedGetTotalCount = () => { + const { data } = useQuery(OBJECT_RECORD_COUNTS); - const { data } = useQuery( - findManyQuery ?? EMPTY_QUERY, - { - skip, - client: apolloCoreClient, - }, - ); - - const totalCountByObjectMetadataItemNamePlural = Object.fromEntries( - Object.entries(data ?? {}).map(([namePlural, objectRecordConnection]) => [ - namePlural, - objectRecordConnection.totalCount, - ]), + const totalCountByObjectMetadataItemNamePlural = useMemo( + () => + Object.fromEntries( + (data?.objectRecordCounts ?? []).map((item) => [ + item.objectNamePlural, + item.totalCount, + ]), + ), + [data], ); return { diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx index 1d2e6dc674..b04e0f2e46 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectTable.tsx @@ -58,11 +58,8 @@ export const SettingsObjectTable = ({ const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem(); - const { totalCountByObjectMetadataItemNamePlural } = useCombinedGetTotalCount( - { - objectMetadataItems, - }, - ); + const { totalCountByObjectMetadataItemNamePlural } = + useCombinedGetTotalCount(); const currentWorkspace = useRecoilValue(currentWorkspaceState); diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto.ts new file mode 100644 index 0000000000..3c7f0724bd --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto.ts @@ -0,0 +1,10 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +@ObjectType('ObjectRecordCount') +export class ObjectRecordCountDTO { + @Field(() => String) + objectNamePlural: string; + + @Field(() => Int) + totalCount: number; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts index d6370a96d1..8ed53ce5cb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts @@ -25,6 +25,7 @@ import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadat import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +import { ObjectRecordCountService } from 'src/engine/metadata-modules/object-metadata/object-record-count.service'; import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; @@ -93,6 +94,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace providers: [ ObjectMetadataService, ObjectMetadataResolver, + ObjectRecordCountService, ObjectMetadataToolsFactory, ], exports: [ObjectMetadataService, ObjectMetadataToolsFactory], diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts index 45de9d3667..28801f9ecd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts @@ -1,8 +1,16 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; -import { Args, Context, Mutation, Parent, ResolveField } from '@nestjs/graphql'; +import { + Args, + Context, + Mutation, + Parent, + Query, + ResolveField, +} from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; @@ -10,7 +18,6 @@ import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.typ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; @@ -19,8 +26,10 @@ import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dto import { CreateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input'; import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; +import { ObjectRecordCountDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto'; import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +import { ObjectRecordCountService } from 'src/engine/metadata-modules/object-metadata/object-record-count.service'; import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util'; import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; @@ -35,9 +44,18 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module export class ObjectMetadataResolver { constructor( private readonly objectMetadataService: ObjectMetadataService, + private readonly objectRecordCountService: ObjectRecordCountService, private readonly i18nService: I18nService, ) {} + @UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL)) + @Query(() => [ObjectRecordCountDTO]) + async objectRecordCounts( + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ): Promise { + return this.objectRecordCountService.getRecordCounts(workspaceId); + } + @ResolveField(() => String, { nullable: true }) async labelPlural( @Parent() objectMetadata: ObjectMetadataDTO, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-record-count.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-record-count.service.ts new file mode 100644 index 0000000000..68e05e3a71 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-record-count.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { type ObjectRecordCountDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { computeTableName } from 'src/engine/utils/compute-table-name.util'; +import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; + +@Injectable() +export class ObjectRecordCountService { + constructor( + private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + async getRecordCounts(workspaceId: string): Promise { + const { flatObjectMetadataMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }, + ); + + const flatObjectMetadatas = Object.values( + flatObjectMetadataMaps.byUniversalIdentifier, + ).filter(isDefined); + + const schemaName = getWorkspaceSchemaName(workspaceId); + + const dataSource = + await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource(); + + const rows: { relname: string; approximate_count: number }[] = + await dataSource.query( + `SELECT relname, reltuples::bigint AS approximate_count + FROM pg_class c + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE n.nspname = $1 + AND c.relkind = 'r'`, + [schemaName], + undefined, + { shouldBypassPermissionChecks: true }, + ); + + const countByTableName = new Map(); + + for (const row of rows) { + countByTableName.set( + row.relname, + Math.max(0, Number(row.approximate_count)), + ); + } + + return flatObjectMetadatas.map((flatObjectMetadata) => ({ + objectNamePlural: flatObjectMetadata.namePlural, + totalCount: + countByTableName.get( + computeTableName( + flatObjectMetadata.nameSingular, + flatObjectMetadata.isCustom, + ), + ) ?? 0, + })); + } +} diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/object-record-counts.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/object-record-counts.integration-spec.ts new file mode 100644 index 0000000000..7c14d7026b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/object-record-counts.integration-spec.ts @@ -0,0 +1,73 @@ +import gql from 'graphql-tag'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +const OBJECT_RECORD_COUNTS_QUERY = gql` + query ObjectRecordCounts { + objectRecordCounts { + objectNamePlural + totalCount + } + } +`; + +describe('objectRecordCounts', () => { + it('should return counts for all workspace objects', async () => { + const response = await makeMetadataAPIRequest({ + query: OBJECT_RECORD_COUNTS_QUERY, + }); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.objectRecordCounts).toBeDefined(); + + const counts: { objectNamePlural: string; totalCount: number }[] = + response.body.data.objectRecordCounts; + + expect(Array.isArray(counts)).toBe(true); + expect(counts.length).toBeGreaterThan(0); + + for (const entry of counts) { + expect(typeof entry.objectNamePlural).toBe('string'); + expect(entry.objectNamePlural.length).toBeGreaterThan(0); + expect(typeof entry.totalCount).toBe('number'); + expect(entry.totalCount).toBeGreaterThanOrEqual(0); + } + }); + + it('should include well-known standard objects', async () => { + const response = await makeMetadataAPIRequest({ + query: OBJECT_RECORD_COUNTS_QUERY, + }); + + const counts: { objectNamePlural: string; totalCount: number }[] = + response.body.data.objectRecordCounts; + + const objectNames = counts.map((entry) => entry.objectNamePlural); + + expect(objectNames).toContain('people'); + expect(objectNames).toContain('companies'); + expect(objectNames).toContain('opportunities'); + }); + + it('should return non-negative totalCount values', async () => { + const response = await makeMetadataAPIRequest({ + query: OBJECT_RECORD_COUNTS_QUERY, + }); + + const counts: { objectNamePlural: string; totalCount: number }[] = + response.body.data.objectRecordCounts; + + const negativeEntries = counts.filter((entry) => entry.totalCount < 0); + + expect(negativeEntries).toHaveLength(0); + }); + + it('should reject requests with an invalid token', async () => { + const response = await makeMetadataAPIRequest( + { query: OBJECT_RECORD_COUNTS_QUERY }, + INVALID_ACCESS_TOKEN, + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBeGreaterThan(0); + }); +});