Add objectRecordCounts query to /metadata endpoint (#18054)

## Summary

- Adds an `objectRecordCounts` query on the `/metadata` GraphQL endpoint
that returns approximate record counts for all objects in the workspace
- Uses PostgreSQL's `pg_class.reltuples` catalog stats — a single
instant query instead of N `COUNT(*)` table scans
- Replaces the previous `CombinedFindManyRecords` approach which hit the
server's 20 root resolver limit and silently showed 0 for all counts on
the settings Data Model page

### Server
- `ObjectRecordCountDTO` — GraphQL type with `objectNamePlural` and
`totalCount`
- `ObjectRecordCountService` — reads `pg_class` catalog for the
workspace schema
- Query added to `ObjectMetadataResolver` with `@MetadataResolver()` +
`NoPermissionGuard`

### Frontend
- `OBJECT_RECORD_COUNTS` query added to
`object-metadata/graphql/queries.ts`
- `useCombinedGetTotalCount` simplified to a zero-argument hook using
the new query
- `SettingsObjectTable` simplified to a single hook call

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-02-19 10:02:31 +01:00
committed by GitHub
parent a14b0ab6ca
commit 082400f751
9 changed files with 250 additions and 47 deletions
@@ -0,0 +1,10 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('ObjectRecordCount')
export class ObjectRecordCountDTO {
@Field(() => String)
objectNamePlural: string;
@Field(() => Int)
totalCount: number;
}
@@ -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],
@@ -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<ObjectRecordCountDTO[]> {
return this.objectRecordCountService.getRecordCounts(workspaceId);
}
@ResolveField(() => String, { nullable: true })
async labelPlural(
@Parent() objectMetadata: ObjectMetadataDTO,
@@ -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<ObjectRecordCountDTO[]> {
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<string, number>();
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,
}));
}
}