Show relation record labels instead of UUIDs in dashboard charts (#23163)

https://github.com/user-attachments/assets/d012a013-2c90-49a1-a27e-b8e4b684a84f



Charts grouped by a relation without a sub-field rendered raw FK UUIDs
on axis ticks, legends and tooltips. The server now batch-resolves the
grouped record ids to their label identifier through a permission-scoped
query and formats every bucket with the record's display name.
Unresolvable records (deleted or not readable) render as Unknown and
their ids are stripped from the response payload. Same-named records get
an ordinal suffix so their buckets don't merge. Covers bar, line and
pie, plain and morph relations.

```mermaid
flowchart TD
    A["Dashboard widget load"] --> B["Chart data service<br/>(bar / line / pie)"]
    B --> C["executeGroupByQuery:<br/>group by relation FK id,<br/>ORDER BY target label identifier,<br/>scoped to source object permissions"]
    C --> D["filterOutEmptyChartBuckets"]
    D --> E{"Bare relation axis?<br/>(no sub-field)"}

    subgraph RL["ChartRelationLabelService.resolveRelationLabels"]
        direction TB
        G1["Collect distinct record ids<br/>per target object"] --> G2["Batch SELECT label identifier columns,<br/>scoped to TARGET object permissions"]
        G2 --> G3["buildRawLabelByRecordId:<br/>display name per record"]
        G3 --> G4["buildUniqueRelationLabels:<br/>suffix duplicates, Unknown for unresolved"]
    end

    E -- No --> H["formatDimensionValue per bucket"]
    E -- Yes --> G1
    G4 --> H
    H --> I["Strip unresolved ids from<br/>formattedToRawLookup"]
    I --> J["Chart DTO to frontend"]
```

The chart settings sub-field dropdown gains a Record option to group by
the related record itself, and now only offers sub-fields the backend
accepts (system fields like a workspace member's updatedBy were
selectable but rejected at query time). Chart-data errors are now logged
server-side.

Also fixes two latent bugs on this path: sorting a bare-relation chart
by field threw `Cannot orderBy unknown field: agentId`, and the pie
chart truncated slices before sorting. The AI dashboard tool guidance
and the seeded dashboards no longer force the sub-field workaround.

The group-by query orders buckets by the related record's label
identifier at the database level (the engine now accepts ordering by a
target field when grouping by its id), so with more than 100 distinct
related records the surviving buckets match the label order.
This commit is contained in:
Raphaël Bosi
2026-07-24 16:12:22 +02:00
committed by GitHub
parent b036d67ec9
commit d1c6b8ee72
62 changed files with 4504 additions and 546 deletions
@@ -2,11 +2,13 @@ import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
OrderByDirection,
RelationType,
} from 'twenty-shared/types';
import {
type GroupByDateField,
type GroupByRegularField,
type GroupByRelationField,
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
import { GraphqlQueryOrderGroupByParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser';
@@ -141,3 +143,274 @@ describe('GraphqlQueryOrderGroupByParser - object alias in order-by clauses', ()
expect(orderByKey).not.toContain(physicalTableName);
});
});
describe('GraphqlQueryOrderGroupByParser - relation order-by under target primary key group-by', () => {
const SOURCE_OBJECT_ID = 'person-object-id';
const SOURCE_OBJECT_UNIVERSAL_ID = 'person-object-universal-id';
const COMPANY_RELATION_FIELD_ID = 'person-company-field-id';
const COMPANY_RELATION_FIELD_UNIVERSAL_ID =
'person-company-field-universal-id';
const MEMBER_RELATION_FIELD_ID = 'person-member-field-id';
const MEMBER_RELATION_FIELD_UNIVERSAL_ID = 'person-member-field-universal-id';
const COMPANY_OBJECT_ID = 'company-object-id';
const COMPANY_OBJECT_UNIVERSAL_ID = 'company-object-universal-id';
const COMPANY_ID_FIELD_ID = 'company-id-field-id';
const COMPANY_ID_FIELD_UNIVERSAL_ID = 'company-id-field-universal-id';
const COMPANY_NAME_FIELD_ID = 'company-name-field-id';
const COMPANY_NAME_FIELD_UNIVERSAL_ID = 'company-name-field-universal-id';
const COMPANY_OWNER_FIELD_ID = 'company-owner-field-id';
const COMPANY_OWNER_FIELD_UNIVERSAL_ID = 'company-owner-field-universal-id';
const MEMBER_OBJECT_ID = 'member-object-id';
const MEMBER_OBJECT_UNIVERSAL_ID = 'member-object-universal-id';
const MEMBER_ID_FIELD_ID = 'member-id-field-id';
const MEMBER_ID_FIELD_UNIVERSAL_ID = 'member-id-field-universal-id';
const MEMBER_NAME_FIELD_ID = 'member-name-field-id';
const MEMBER_NAME_FIELD_UNIVERSAL_ID = 'member-name-field-universal-id';
const companyRelationField = getFlatFieldMetadataMock({
universalIdentifier: COMPANY_RELATION_FIELD_UNIVERSAL_ID,
objectMetadataId: SOURCE_OBJECT_ID,
type: FieldMetadataType.RELATION,
id: COMPANY_RELATION_FIELD_ID,
name: 'company',
relationTargetObjectMetadataId: COMPANY_OBJECT_ID,
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'companyId',
},
});
const memberRelationField = getFlatFieldMetadataMock({
universalIdentifier: MEMBER_RELATION_FIELD_UNIVERSAL_ID,
objectMetadataId: SOURCE_OBJECT_ID,
type: FieldMetadataType.RELATION,
id: MEMBER_RELATION_FIELD_ID,
name: 'member',
relationTargetObjectMetadataId: MEMBER_OBJECT_ID,
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'memberId',
},
});
const companyIdField = getFlatFieldMetadataMock({
universalIdentifier: COMPANY_ID_FIELD_UNIVERSAL_ID,
objectMetadataId: COMPANY_OBJECT_ID,
type: FieldMetadataType.UUID,
id: COMPANY_ID_FIELD_ID,
name: 'id',
});
const companyNameField = getFlatFieldMetadataMock({
universalIdentifier: COMPANY_NAME_FIELD_UNIVERSAL_ID,
objectMetadataId: COMPANY_OBJECT_ID,
type: FieldMetadataType.TEXT,
id: COMPANY_NAME_FIELD_ID,
name: 'name',
});
const companyOwnerField = getFlatFieldMetadataMock({
universalIdentifier: COMPANY_OWNER_FIELD_UNIVERSAL_ID,
objectMetadataId: COMPANY_OBJECT_ID,
type: FieldMetadataType.RELATION,
id: COMPANY_OWNER_FIELD_ID,
name: 'owner',
relationTargetObjectMetadataId: MEMBER_OBJECT_ID,
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'ownerId',
},
});
const memberIdField = getFlatFieldMetadataMock({
universalIdentifier: MEMBER_ID_FIELD_UNIVERSAL_ID,
objectMetadataId: MEMBER_OBJECT_ID,
type: FieldMetadataType.UUID,
id: MEMBER_ID_FIELD_ID,
name: 'id',
});
const memberNameField = getFlatFieldMetadataMock({
universalIdentifier: MEMBER_NAME_FIELD_UNIVERSAL_ID,
objectMetadataId: MEMBER_OBJECT_ID,
type: FieldMetadataType.FULL_NAME,
id: MEMBER_NAME_FIELD_ID,
name: 'name',
});
const sourceObjectMetadata = getFlatObjectMetadataMock({
universalIdentifier: SOURCE_OBJECT_UNIVERSAL_ID,
id: SOURCE_OBJECT_ID,
nameSingular: 'person',
namePlural: 'people',
fieldIds: [COMPANY_RELATION_FIELD_ID, MEMBER_RELATION_FIELD_ID],
});
const companyObjectMetadata = getFlatObjectMetadataMock({
universalIdentifier: COMPANY_OBJECT_UNIVERSAL_ID,
id: COMPANY_OBJECT_ID,
nameSingular: 'company',
namePlural: 'companies',
fieldIds: [
COMPANY_ID_FIELD_ID,
COMPANY_NAME_FIELD_ID,
COMPANY_OWNER_FIELD_ID,
],
});
const memberObjectMetadata = getFlatObjectMetadataMock({
universalIdentifier: MEMBER_OBJECT_UNIVERSAL_ID,
id: MEMBER_OBJECT_ID,
nameSingular: 'member',
namePlural: 'members',
fieldIds: [MEMBER_ID_FIELD_ID, MEMBER_NAME_FIELD_ID],
});
const flatFieldMetadataMaps = {
byUniversalIdentifier: {
[COMPANY_RELATION_FIELD_UNIVERSAL_ID]: companyRelationField,
[MEMBER_RELATION_FIELD_UNIVERSAL_ID]: memberRelationField,
[COMPANY_ID_FIELD_UNIVERSAL_ID]: companyIdField,
[COMPANY_NAME_FIELD_UNIVERSAL_ID]: companyNameField,
[COMPANY_OWNER_FIELD_UNIVERSAL_ID]: companyOwnerField,
[MEMBER_ID_FIELD_UNIVERSAL_ID]: memberIdField,
[MEMBER_NAME_FIELD_UNIVERSAL_ID]: memberNameField,
},
universalIdentifierById: {
[COMPANY_RELATION_FIELD_ID]: COMPANY_RELATION_FIELD_UNIVERSAL_ID,
[MEMBER_RELATION_FIELD_ID]: MEMBER_RELATION_FIELD_UNIVERSAL_ID,
[COMPANY_ID_FIELD_ID]: COMPANY_ID_FIELD_UNIVERSAL_ID,
[COMPANY_NAME_FIELD_ID]: COMPANY_NAME_FIELD_UNIVERSAL_ID,
[COMPANY_OWNER_FIELD_ID]: COMPANY_OWNER_FIELD_UNIVERSAL_ID,
[MEMBER_ID_FIELD_ID]: MEMBER_ID_FIELD_UNIVERSAL_ID,
[MEMBER_NAME_FIELD_ID]: MEMBER_NAME_FIELD_UNIVERSAL_ID,
},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatFieldMetadata>;
const flatObjectMetadataMaps = {
byUniversalIdentifier: {
[SOURCE_OBJECT_UNIVERSAL_ID]: sourceObjectMetadata,
[COMPANY_OBJECT_UNIVERSAL_ID]: companyObjectMetadata,
[MEMBER_OBJECT_UNIVERSAL_ID]: memberObjectMetadata,
},
universalIdentifierById: {
[SOURCE_OBJECT_ID]: SOURCE_OBJECT_UNIVERSAL_ID,
[COMPANY_OBJECT_ID]: COMPANY_OBJECT_UNIVERSAL_ID,
[MEMBER_OBJECT_ID]: MEMBER_OBJECT_UNIVERSAL_ID,
},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatObjectMetadata>;
const groupByCompanyId: GroupByRelationField = {
fieldMetadata: companyRelationField,
nestedFieldMetadata: companyIdField,
};
const groupByMemberId: GroupByRelationField = {
fieldMetadata: memberRelationField,
nestedFieldMetadata: memberIdField,
};
const buildParser = () =>
new GraphqlQueryOrderGroupByParser(
sourceObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
it('orders by a target scalar field when grouping by the target id', () => {
const parser = buildParser();
const result = parser.parse({
orderBy: [{ company: { name: OrderByDirection.AscNullsLast } }],
groupByFields: [groupByCompanyId],
});
expect(result).toEqual([
{ '"company"."name"': { order: 'ASC', nulls: 'NULLS LAST' } },
]);
});
it('orders by FULL_NAME subfields when grouping by the target id', () => {
const parser = buildParser();
const result = parser.parse({
orderBy: [
{ member: { name: { firstName: OrderByDirection.AscNullsLast } } },
{ member: { name: { lastName: OrderByDirection.AscNullsLast } } },
],
groupByFields: [groupByMemberId],
});
expect(result).toEqual([
{ '"member"."nameFirstName"': { order: 'ASC', nulls: 'NULLS LAST' } },
{ '"member"."nameLastName"': { order: 'ASC', nulls: 'NULLS LAST' } },
]);
});
it('still orders by the exact nested field present in groupBy', () => {
const parser = buildParser();
const groupByCompanyName: GroupByRelationField = {
fieldMetadata: companyRelationField,
nestedFieldMetadata: companyNameField,
};
const result = parser.parse({
orderBy: [{ company: { name: OrderByDirection.DescNullsLast } }],
groupByFields: [groupByCompanyName],
});
expect(result).toEqual([
{ '"company"."name"': { order: 'DESC', nulls: 'NULLS LAST' } },
]);
});
it('throws when ordering by a relation absent from groupBy', () => {
const parser = buildParser();
expect(() =>
parser.parse({
orderBy: [
{ member: { name: { firstName: OrderByDirection.AscNullsLast } } },
],
groupByFields: [groupByCompanyId],
}),
).toThrow(
'Cannot order by a relation field that is not in groupBy criteria: member.name',
);
});
it('throws when ordering by an unknown composite subfield under target id group-by', () => {
const parser = buildParser();
expect(() =>
parser.parse({
orderBy: [
{
member: {
name: { unknownSubField: OrderByDirection.AscNullsLast },
},
},
],
groupByFields: [groupByMemberId],
}),
).toThrow(
'Composite subfield "unknownSubField" is not orderable for "name"',
);
});
it('throws when ordering by a nested relation field under target id group-by', () => {
const parser = buildParser();
expect(() =>
parser.parse({
orderBy: [{ company: { owner: OrderByDirection.AscNullsLast } }],
groupByFields: [groupByCompanyId],
}),
).toThrow(
'Cannot order by a relation field that is not in groupBy criteria: company.owner',
);
});
});
@@ -29,6 +29,7 @@ import {
getAvailableAggregationsFromObjectFields,
} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -524,6 +525,7 @@ export class GraphqlQueryOrderGroupByParser {
associatedGroupByField,
nestedFieldMetadata,
nestedFieldOrderByValue,
isMatchedOnTargetPrimaryKeyGroupBy,
} = prepareForOrderByRelationFieldParsing({
orderByArg,
fieldMetadata,
@@ -565,7 +567,17 @@ export class GraphqlQueryOrderGroupByParser {
return null;
}
if (
if (isMatchedOnTargetPrimaryKeyGroupBy === true) {
const groupableSubFields = getGroupableSubFieldsForCompositeType(
nestedFieldMetadata.type,
);
if (!groupableSubFields?.includes(nestedSubFieldName)) {
throw new UserInputError(
`Composite subfield "${nestedSubFieldName}" is not orderable for "${nestedFieldMetadata.name}"`,
);
}
} else if (
!isDefined(associatedGroupByField.nestedSubFieldName) ||
associatedGroupByField.nestedSubFieldName !== nestedSubFieldName
) {
@@ -9,6 +9,7 @@ import {
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -106,7 +107,7 @@ export const prepareForOrderByRelationFieldParsing = ({
}
}
const associatedGroupByField = groupByFields.find((groupByField) => {
const exactGroupByFieldMatch = groupByFields.find((groupByField) => {
if (!isGroupByRelationField(groupByField)) {
return false;
}
@@ -126,6 +127,23 @@ export const prepareForOrderByRelationFieldParsing = ({
return true;
}) as GroupByRelationField | undefined;
const canOrderByFunctionallyDependentNestedField =
!isMorphOrRelationFlatFieldMetadata(nestedFieldMetadata);
const groupByFieldOnTargetPrimaryKey =
!isDefined(exactGroupByFieldMatch) &&
canOrderByFunctionallyDependentNestedField
? (groupByFields.find(
(groupByField) =>
isGroupByRelationField(groupByField) &&
groupByField.fieldMetadata.id === fieldMetadata.id &&
groupByField.nestedFieldMetadata.name === 'id',
) as GroupByRelationField | undefined)
: undefined;
const associatedGroupByField =
exactGroupByFieldMatch ?? groupByFieldOnTargetPrimaryKey;
if (!isDefined(associatedGroupByField)) {
throw new UserInputError(
`Cannot order by a relation field that is not in groupBy criteria: ${relationFieldName}.${nestedFieldName}`,
@@ -136,5 +154,6 @@ export const prepareForOrderByRelationFieldParsing = ({
associatedGroupByField,
nestedFieldMetadata,
nestedFieldOrderByValue,
isMatchedOnTargetPrimaryKeyGroupBy: !isDefined(exactGroupByFieldMatch),
};
};
@@ -299,6 +299,14 @@ export const validateQueryIsPermittedOrThrow = ({
});
expressionMapSelectsOnMainEntity = selectsWithoutJoinedAliases;
validateJoinedOrderByColumnsArePermittedOrThrow({
expressionMap,
objectsPermissions,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
});
}
const allFieldsSelected = expressionMapSelectsOnMainEntity.some(
@@ -411,6 +419,63 @@ const validatePermissionsForJoinsAndReturnSelectsWithoutJoins = ({
return { selectsWithoutJoinedAliases };
};
const validateJoinedOrderByColumnsArePermittedOrThrow = ({
expressionMap,
objectsPermissions,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
}: {
expressionMap: QueryExpressionMap;
objectsPermissions: ObjectsPermissions;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
objectIdByNameSingular: Record<string, string>;
}) => {
const columnsByJoinedAlias = new Map<string, Set<string>>();
const columnReferenceRegex = /"(\w+)"\."(\w+)"/g;
for (const orderByExpression of Object.keys(expressionMap.orderBys)) {
for (const [, alias, column] of orderByExpression.matchAll(
columnReferenceRegex,
)) {
const columnsForAlias = columnsByJoinedAlias.get(alias) ?? new Set();
columnsForAlias.add(column);
columnsByJoinedAlias.set(alias, columnsForAlias);
}
}
for (const joinAttribute of expressionMap.joinAttributes) {
const joinedAlias = joinAttribute.alias.name;
const referencedColumns = columnsByJoinedAlias.get(joinedAlias);
if (!isDefined(referencedColumns)) {
continue;
}
const entity = expressionMap.aliases.find(
(alias) => alias.type === 'join' && alias.name === joinedAlias,
)?.metadata;
if (!isDefined(entity)) {
continue;
}
validateOperationIsPermittedOrThrow({
entityName: entity.name,
operationType: 'select',
objectsPermissions,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
selectedColumns: [...referencedColumns],
allFieldsSelected: false,
updatedColumns: [],
});
}
};
const buildFieldPermissionDeniedMessage = ({
action,
column,
@@ -490,11 +490,9 @@ const createOpportunitiesByOwner = ({
aggregateFieldMetadataId: opportunityFields.id.id,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataId: opportunityFields.owner.id,
primaryAxisGroupBySubFieldName: 'name.firstName',
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.DAY,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
secondaryAxisGroupByFieldMetadataId: opportunityFields.owner.id,
secondaryAxisGroupBySubFieldName: 'name.firstName',
secondaryAxisGroupByDateGranularity:
ObjectRecordGroupByDateGranularity.DAY,
secondaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
@@ -514,12 +512,10 @@ const createOpportunitiesByOwner = ({
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
STANDARD_OBJECTS.opportunity.fields.owner.universalIdentifier,
primaryAxisGroupBySubFieldName: 'name.firstName',
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.DAY,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
secondaryAxisGroupByFieldMetadataUniversalIdentifier:
STANDARD_OBJECTS.opportunity.fields.owner.universalIdentifier,
secondaryAxisGroupBySubFieldName: 'name.firstName',
secondaryAxisGroupByDateGranularity:
ObjectRecordGroupByDateGranularity.DAY,
secondaryAxisOrderBy: GraphOrderBy.FIELD_ASC,