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
@@ -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<NavigationMenuItem>;
navigationMenuItems: Array<NavigationMenuItem>;
object: Object;
objectRecordCounts: Array<ObjectRecordCount>;
objects: ObjectConnection;
pieChartData: PieChartDataOutput;
skill?: Maybe<Skill>;
@@ -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<Array<string>> | 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<typeof useObjectMetadataItemsQuery>;
export type ObjectMetadataItemsLazyQueryHookResult = ReturnType<typeof useObjectMetadataItemsLazyQuery>;
export type ObjectMetadataItemsQueryResult = Apollo.QueryResult<ObjectMetadataItemsQuery, ObjectMetadataItemsQueryVariables>;
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<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>(ObjectRecordCountsDocument, options);
}
export function useObjectRecordCountsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>(ObjectRecordCountsDocument, options);
}
export type ObjectRecordCountsQueryHookResult = ReturnType<typeof useObjectRecordCountsQuery>;
export type ObjectRecordCountsLazyQueryHookResult = ReturnType<typeof useObjectRecordCountsLazyQuery>;
export type ObjectRecordCountsQueryResult = Apollo.QueryResult<ObjectRecordCountsQuery, ObjectRecordCountsQueryVariables>;
export const SkipBookOnboardingStepDocument = gql`
mutation SkipBookOnboardingStep {
skipBookOnboardingStep {
@@ -19,3 +19,12 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
}
}
`;
export const OBJECT_RECORD_COUNTS = gql`
query ObjectRecordCounts {
objectRecordCounts {
objectNamePlural
totalCount
}
}
`;
@@ -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<ObjectRecordCountsResult>(OBJECT_RECORD_COUNTS);
const { data } = useQuery<CombinedFindManyRecordsQueryResult>(
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 {
@@ -58,11 +58,8 @@ export const SettingsObjectTable = ({
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const { totalCountByObjectMetadataItemNamePlural } = useCombinedGetTotalCount(
{
objectMetadataItems,
},
);
const { totalCountByObjectMetadataItemNamePlural } =
useCombinedGetTotalCount();
const currentWorkspace = useRecoilValue(currentWorkspaceState);