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:
+10
@@ -0,0 +1,10 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('ObjectRecordCount')
|
||||
export class ObjectRecordCountDTO {
|
||||
@Field(() => String)
|
||||
objectNamePlural: string;
|
||||
|
||||
@Field(() => Int)
|
||||
totalCount: number;
|
||||
}
|
||||
+2
@@ -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],
|
||||
|
||||
+20
-2
@@ -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,
|
||||
|
||||
+68
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user