Fix various graphs bugs (#21311)

Some bugs fixed in this PR
1. From UI any field could be chosen to group the query by it, while for
instance, RAW_JSON type (eg workflowRun.state) is not supported by
PostgreSQL to group a query by. Fix: removed it from the "group by"
fields options in FE + in BE -->
2. The BE check existed (isFlatFieldMetadataSupportedInGroupBy) but the
signature was malformed: it expected`{ fieldMetadataType,
fieldMetadataName, fieldMetadataIsSystem }` while every caller passes a
flat field metadata object with type/name/isSystem. So the check is
mis-wired — at runtime the destructured props are undefined, making it
always return true (validation bypassed). Fixed this.
3. Group by does not work with Morph relations if their direction is
ONE_TO_MANY. Added that constraint.
4. Group by with morph relations were broken even for MANY_TO_ONE,
because a morph is stored as one field per target
(polymorphicOwnerRocket, polymorphicOwnerSurveyResult…), each with its
own join column, but the frontend collapsed them into a single
polymorphicOwner field — so the backend tried to resolve a non-existent
polymorphicOwnerId. Fix: Frontend: added a target picker so you choose
the specific morph target (then its sub-field), storing the real
per-target field id. Backend: fixed validate-relation-subfield to use
the per-target field's own relationTargetObjectMetadataId instead of the
multi-target resolver that returned null.
5. (improvement) When an error occured in the query, the graph showed
"No data". Updated it to "error". (screenshot 1)
6. When a field used as a filter on a graph is deleted, it is not
deleted as a graph filter (which is ok because it would involve parsing
all the graph's configuration json to find whether a field is
referenced; there is no foreign key), which prevented from further
modifying the graph's filters. Fixed this + add an indicator that the
filter is can/should be removed (see screenshot 2)
7. "Ambiguous column name" PG error occurs when ordering by "creation
date" of a related field, because both objects have createdAt field.
Fixed it by adding table alias as prefix.
8. (improvement) While working on #5 I did not understand why we could
directly do `"objectMetadataNameSingular"."columnName" `while I expected
that for custom objects it would have to be
`_objectMetadataNameSingular`. that's simply because we use an alias
from the beginning. To add clarity, within groupBy code I replaced
`objectMetadataNameSingular` with `objectAlias` everywhere it is indeed
inherited from us using objectAlias.

<img width="685" height="391" alt="Screenshot 2026-06-08 at 12 01 45"
src="https://github.com/user-attachments/assets/f2b15ca5-da39-4114-8188-69f58f3c4cbf"
/>

<img width="598" height="341" alt="Screenshot 2026-06-08 at 11 53 55"
src="https://github.com/user-attachments/assets/66372811-4a37-40d9-b43a-4af51f89b6e6"
/>
This commit is contained in:
Marie
2026-06-09 16:08:22 +02:00
committed by GitHub
parent 137fe45cf6
commit c27c8c88b0
49 changed files with 1464 additions and 414 deletions
@@ -1,15 +1,7 @@
import { type CompositeProperty, FieldMetadataType } from 'twenty-shared/types';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
import { isCompositePropertySupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-property-supported-in-group-by.util';
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
const buildFlatFieldMetadata = (
type: FieldMetadataType,
name = 'field',
isSystem = false,
): FlatFieldMetadata => ({ type, name, isSystem }) as FlatFieldMetadata;
const buildCompositeProperty = (
type: FieldMetadataType,
@@ -21,87 +13,6 @@ const buildCompositeProperty = (
isRequired: false,
});
describe('isFlatFieldMetadataSupportedInGroupBy', () => {
it('returns false for low-level field types', () => {
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.TS_VECTOR),
),
).toBe(false);
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.RAW_JSON),
),
).toBe(false);
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.FILES),
),
).toBe(false);
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.POSITION),
),
).toBe(false);
});
it('returns true for regular field types', () => {
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.TEXT),
),
).toBe(true);
});
it('returns false for internal/system field names', () => {
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.TEXT, 'id'),
),
).toBe(false);
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'deletedAt'),
),
).toBe(false);
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.TS_VECTOR, 'searchVector'),
),
).toBe(false);
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.ACTOR, 'createdBy'),
),
).toBe(false);
});
it('returns true for createdAt and updatedAt date fields even if system', () => {
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'createdAt', true),
),
).toBe(true);
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'updatedAt', true),
),
).toBe(true);
});
it('returns false for other system fields', () => {
expect(
isFlatFieldMetadataSupportedInGroupBy(
buildFlatFieldMetadata(
FieldMetadataType.TEXT,
'customSystemField',
true,
),
),
).toBe(false);
});
});
describe('isCompositePropertySupportedInGroupBy', () => {
it('returns false for hidden or raw_json composite properties', () => {
expect(
@@ -1,34 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isFieldMetadataDateKind } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
const NON_GROUPABLE_FIELD_TYPES = new Set<FieldMetadataType>([
FieldMetadataType.TS_VECTOR,
FieldMetadataType.RAW_JSON,
FieldMetadataType.FILES,
FieldMetadataType.POSITION,
]);
const ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES = new Set([
'createdAt',
'updatedAt',
]);
export const isFlatFieldMetadataSupportedInGroupBy = (
fieldMetadata: FlatFieldMetadata,
): boolean => {
const isAlwaysGroupableSystemDateField =
ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES.has(fieldMetadata.name) &&
isFieldMetadataDateKind(fieldMetadata.type);
if (
!isAlwaysGroupableSystemDateField &&
shouldExcludeFieldFromAgentToolSchema(fieldMetadata)
) {
return false;
}
return !NON_GROUPABLE_FIELD_TYPES.has(fieldMetadata.type);
};
@@ -1,21 +0,0 @@
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export const shouldExcludeFieldFromAgentToolSchema = (
field: FieldMetadataEntity | FlatFieldMetadata,
excludeId = true,
additionalExcludedFieldNames: string[] = [],
): boolean => {
const excludedFieldNames = [
'deletedAt',
'searchVector',
'createdBy',
...additionalExcludedFieldNames,
];
if (excludeId) {
excludedFieldNames.push('id');
}
return excludedFieldNames.includes(field.name) || field.isSystem;
};
@@ -0,0 +1,96 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { validateRelationSubfield } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util';
const PET_OBJECT_ID = 'pet-object-id';
const ROCKET_OBJECT_ID = 'rocket-object-id';
const SURVEY_RESULT_OBJECT_ID = 'survey-result-object-id';
const buildFieldsByObjectId = (
fields: FlatFieldMetadata[],
): Map<string, FlatFieldMetadata[]> => {
const map = new Map<string, FlatFieldMetadata[]>();
for (const field of fields) {
const existing = map.get(field.objectMetadataId) ?? [];
existing.push(field);
map.set(field.objectMetadataId, existing);
}
return map;
};
describe('validateRelationSubfield', () => {
it('should resolve the concrete target for a per-target morph field with multiple targets', () => {
const morphId = 'polymorphic-owner-morph-id';
const morphToRocket = getFlatFieldMetadataMock({
universalIdentifier: 'morph-rocket',
objectMetadataId: PET_OBJECT_ID,
type: FieldMetadataType.MORPH_RELATION,
name: 'polymorphicOwnerRocket',
morphId,
relationTargetObjectMetadataId: ROCKET_OBJECT_ID,
});
const morphToSurveyResult = getFlatFieldMetadataMock({
universalIdentifier: 'morph-survey-result',
objectMetadataId: PET_OBJECT_ID,
type: FieldMetadataType.MORPH_RELATION,
name: 'polymorphicOwnerSurveyResult',
morphId,
relationTargetObjectMetadataId: SURVEY_RESULT_OBJECT_ID,
});
const rocketNameField = getFlatFieldMetadataMock({
universalIdentifier: 'rocket-name',
objectMetadataId: ROCKET_OBJECT_ID,
type: FieldMetadataType.TEXT,
name: 'name',
});
const allFields = [morphToRocket, morphToSurveyResult, rocketNameField];
expect(() =>
validateRelationSubfield({
field: morphToRocket,
subFieldName: 'name',
paramName: morphToRocket.name,
allFields,
fieldsByObjectId: buildFieldsByObjectId(allFields),
}),
).not.toThrow();
});
it('should throw when the nested subfield does not exist on the resolved target', () => {
const morphId = 'polymorphic-owner-morph-id';
const morphToRocket = getFlatFieldMetadataMock({
universalIdentifier: 'morph-rocket',
objectMetadataId: PET_OBJECT_ID,
type: FieldMetadataType.MORPH_RELATION,
name: 'polymorphicOwnerRocket',
morphId,
relationTargetObjectMetadataId: ROCKET_OBJECT_ID,
});
const rocketNameField = getFlatFieldMetadataMock({
universalIdentifier: 'rocket-name',
objectMetadataId: ROCKET_OBJECT_ID,
type: FieldMetadataType.TEXT,
name: 'name',
});
const allFields = [morphToRocket, rocketNameField];
expect(() =>
validateRelationSubfield({
field: morphToRocket,
subFieldName: 'nonExistingField',
paramName: morphToRocket.name,
allFields,
fieldsByObjectId: buildFieldsByObjectId(allFields),
}),
).toThrow('not found');
});
});
@@ -40,7 +40,10 @@ export const validateRelationSubfield = ({
let targetObjectId = field.relationTargetObjectMetadataId ?? null;
if (field.type === FieldMetadataType.MORPH_RELATION) {
if (
field.type === FieldMetadataType.MORPH_RELATION &&
!isDefined(targetObjectId)
) {
targetObjectId = resolveMorphTargetObjectId({ field, allFields });
}