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:
+13
-2
@@ -1,5 +1,7 @@
|
||||
import { isFieldMetadataSupportedInGroupBy } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
|
||||
export const isRelationNestedFieldSupportedInGroupBy = ({
|
||||
nestedFieldName,
|
||||
@@ -12,5 +14,14 @@ export const isRelationNestedFieldSupportedInGroupBy = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
return isFlatFieldMetadataSupportedInGroupBy(nestedFieldMetadata);
|
||||
const relationType = isMorphOrRelationFlatFieldMetadata(nestedFieldMetadata)
|
||||
? nestedFieldMetadata.settings.relationType
|
||||
: null;
|
||||
|
||||
return isFieldMetadataSupportedInGroupBy({
|
||||
type: nestedFieldMetadata.type,
|
||||
name: nestedFieldMetadata.name,
|
||||
isSystem: nestedFieldMetadata.isSystem,
|
||||
relationType,
|
||||
});
|
||||
};
|
||||
|
||||
+23
-9
@@ -1,25 +1,28 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
isPlainObject,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
|
||||
import { validateAndTransformRelationGroupByFieldOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-relation-group-by-field-or-throw.util';
|
||||
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
|
||||
import { validateAndTransformRelationGroupByFieldOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-relation-group-by-field-or-throw.util';
|
||||
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import {
|
||||
ObjectRecordGroupByForAtomicField,
|
||||
ObjectRecordGroupByForCompositeField,
|
||||
ObjectRecordGroupByForDateField,
|
||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
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';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
@@ -136,7 +139,18 @@ const validateAndTransformSingleGroupByFieldOrThrow = ({
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
|
||||
const relationType = isMorphOrRelationFlatFieldMetadata(fieldMetadata)
|
||||
? fieldMetadata.settings.relationType
|
||||
: null;
|
||||
|
||||
if (
|
||||
!isFieldMetadataSupportedInGroupBy({
|
||||
type: fieldMetadata.type,
|
||||
name: fieldMetadata.name,
|
||||
isSystem: fieldMetadata.isSystem,
|
||||
relationType,
|
||||
})
|
||||
) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Field "${fieldName}" is not supported in groupBy`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
|
||||
+6
-10
@@ -87,11 +87,9 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
authContext,
|
||||
} = queryRunnerContext;
|
||||
|
||||
const objectMetadataNameSingular = flatObjectMetadata.nameSingular;
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
let queryBuilder = repository.createQueryBuilder(
|
||||
objectMetadataNameSingular,
|
||||
);
|
||||
let queryBuilder = repository.createQueryBuilder(objectAlias);
|
||||
|
||||
const groupByFields =
|
||||
this.groupByArgProcessor.validateAndTransformGroupByFieldsOrThrow({
|
||||
@@ -101,8 +99,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
this.addJoinForGroupByOnRelationFields({
|
||||
queryBuilder,
|
||||
groupByFields,
|
||||
@@ -126,12 +122,12 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
|
||||
selectedAggregatedFields: args.selectedFieldsResult.aggregate,
|
||||
queryBuilder,
|
||||
objectMetadataNameSingular,
|
||||
objectMetadataNameSingular: objectAlias,
|
||||
});
|
||||
|
||||
const groupByDefinitions = getGroupByDefinitions({
|
||||
groupByFields,
|
||||
objectMetadataNameSingular,
|
||||
objectMetadataNameSingular: objectAlias,
|
||||
});
|
||||
|
||||
groupByDefinitions.forEach((groupByColumn, index) => {
|
||||
@@ -308,7 +304,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
workspaceId: string;
|
||||
commonQueryParser: GraphqlQueryParser;
|
||||
}): Promise<void> {
|
||||
const objectMetadataNameSingular = flatObjectMetadata.nameSingular;
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
if (args.viewId) {
|
||||
appliedFilters = await this.addFiltersFromView({
|
||||
@@ -322,7 +318,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
|
||||
commonQueryParser.applyFilterToBuilder(
|
||||
queryBuilder,
|
||||
objectMetadataNameSingular,
|
||||
objectAlias,
|
||||
appliedFilters,
|
||||
);
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
OrderByDirection,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import {
|
||||
type GroupByDateField,
|
||||
type GroupByRegularField,
|
||||
} 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';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
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 { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
|
||||
|
||||
// The query builder aliases the FROM table with `getObjectAlias` (= nameSingular),
|
||||
// NOT the physical table name (which is `_`-prefixed for custom objects via
|
||||
// `computeTableName`). These tests pin order-by clauses to that alias so the
|
||||
// parser stays in sync if the alias convention ever changes.
|
||||
describe('GraphqlQueryOrderGroupByParser - object alias in order-by clauses', () => {
|
||||
const OBJECT_ID = 'rocket-object-id';
|
||||
const OBJECT_UNIVERSAL_ID = 'rocket-object-universal-id';
|
||||
const NAME_FIELD_ID = 'rocket-name-field-id';
|
||||
const NAME_FIELD_UNIVERSAL_ID = 'rocket-name-field-universal-id';
|
||||
const DATE_FIELD_ID = 'rocket-launched-at-field-id';
|
||||
const DATE_FIELD_UNIVERSAL_ID = 'rocket-launched-at-field-universal-id';
|
||||
|
||||
const nameField = getFlatFieldMetadataMock({
|
||||
universalIdentifier: NAME_FIELD_UNIVERSAL_ID,
|
||||
objectMetadataId: OBJECT_ID,
|
||||
type: FieldMetadataType.TEXT,
|
||||
id: NAME_FIELD_ID,
|
||||
name: 'name',
|
||||
});
|
||||
|
||||
const launchedAtField = getFlatFieldMetadataMock({
|
||||
universalIdentifier: DATE_FIELD_UNIVERSAL_ID,
|
||||
objectMetadataId: OBJECT_ID,
|
||||
type: FieldMetadataType.DATE,
|
||||
id: DATE_FIELD_ID,
|
||||
name: 'launchedAt',
|
||||
});
|
||||
|
||||
// Custom object: physical table is `_rocket`, while the alias is `rocket`.
|
||||
const flatObjectMetadata = getFlatObjectMetadataMock({
|
||||
universalIdentifier: OBJECT_UNIVERSAL_ID,
|
||||
id: OBJECT_ID,
|
||||
nameSingular: 'rocket',
|
||||
namePlural: 'rockets',
|
||||
isCustom: true,
|
||||
fieldIds: [NAME_FIELD_ID, DATE_FIELD_ID],
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = {
|
||||
byUniversalIdentifier: {
|
||||
[NAME_FIELD_UNIVERSAL_ID]: nameField,
|
||||
[DATE_FIELD_UNIVERSAL_ID]: launchedAtField,
|
||||
},
|
||||
universalIdentifierById: {
|
||||
[NAME_FIELD_ID]: NAME_FIELD_UNIVERSAL_ID,
|
||||
[DATE_FIELD_ID]: DATE_FIELD_UNIVERSAL_ID,
|
||||
},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
} as unknown as FlatEntityMaps<FlatFieldMetadata>;
|
||||
|
||||
const flatObjectMetadataMaps = {
|
||||
byUniversalIdentifier: {
|
||||
[OBJECT_UNIVERSAL_ID]: flatObjectMetadata,
|
||||
},
|
||||
universalIdentifierById: {
|
||||
[OBJECT_ID]: OBJECT_UNIVERSAL_ID,
|
||||
},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
} as unknown as FlatEntityMaps<FlatObjectMetadata>;
|
||||
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
const physicalTableName = computeTableName(
|
||||
flatObjectMetadata.nameSingular,
|
||||
flatObjectMetadata.isCustom,
|
||||
);
|
||||
|
||||
const buildParser = () =>
|
||||
new GraphqlQueryOrderGroupByParser(
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
it('uses the object alias as the table prefix for a custom object', () => {
|
||||
// Guards the assumption being tested: a custom object has a `_`-prefixed
|
||||
// physical table that is different from its query alias.
|
||||
expect(physicalTableName).toBe('_rocket');
|
||||
expect(objectAlias).toBe('rocket');
|
||||
expect(physicalTableName).not.toBe(objectAlias);
|
||||
});
|
||||
|
||||
it('prefixes a scalar group-by order-by with the alias, not the physical table', () => {
|
||||
const parser = buildParser();
|
||||
|
||||
const groupByField: GroupByRegularField = { fieldMetadata: nameField };
|
||||
|
||||
const result = parser.parse({
|
||||
orderBy: [{ name: OrderByDirection.AscNullsFirst }],
|
||||
groupByFields: [groupByField],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const orderByKey = Object.keys(result[0])[0];
|
||||
|
||||
expect(orderByKey).toBe(`"${objectAlias}"."name"`);
|
||||
expect(orderByKey).not.toContain(physicalTableName);
|
||||
});
|
||||
|
||||
it('prefixes a date-granularity group-by order-by with the alias, not the physical table', () => {
|
||||
const parser = buildParser();
|
||||
|
||||
const groupByField: GroupByDateField = {
|
||||
fieldMetadata: launchedAtField,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.NONE,
|
||||
};
|
||||
|
||||
const result = parser.parse({
|
||||
orderBy: [
|
||||
{
|
||||
launchedAt: {
|
||||
orderBy: OrderByDirection.AscNullsFirst,
|
||||
granularity: ObjectRecordGroupByDateGranularity.NONE,
|
||||
},
|
||||
},
|
||||
],
|
||||
groupByFields: [groupByField],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const orderByKey = Object.keys(result[0])[0];
|
||||
|
||||
expect(orderByKey).toContain(`"${objectAlias}".`);
|
||||
expect(orderByKey).not.toContain(physicalTableName);
|
||||
});
|
||||
});
|
||||
+7
-12
@@ -18,6 +18,7 @@ import {
|
||||
type GroupByRegularField,
|
||||
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { getGroupByOrderExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-order-expression.util';
|
||||
import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
|
||||
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
|
||||
import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util';
|
||||
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
|
||||
@@ -45,6 +46,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
private flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
private flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
private fieldIdByName: Record<string, string>;
|
||||
private objectAlias: string;
|
||||
|
||||
constructor(
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
@@ -54,6 +56,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
this.flatObjectMetadata = flatObjectMetadata;
|
||||
this.flatObjectMetadataMaps = flatObjectMetadataMaps;
|
||||
this.flatFieldMetadataMaps = flatFieldMetadataMaps;
|
||||
this.objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
const fieldMaps = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
@@ -85,7 +88,6 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
const parsedAggregateOrderBy = this.parseAggregateOrderByArg(
|
||||
availableAggregations,
|
||||
orderByArg,
|
||||
this.flatObjectMetadata,
|
||||
);
|
||||
|
||||
parsedOrderBy.push(parsedAggregateOrderBy);
|
||||
@@ -114,7 +116,6 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
this.parseObjectRecordOrderByForScalarField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata: this.flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
@@ -170,7 +171,6 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
this.parseObjectRecordOrderByForCompositeField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata: this.flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
@@ -339,7 +339,6 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
private parseAggregateOrderByArg = (
|
||||
availableAggregations: Record<string, AggregationField>,
|
||||
orderByArg: AggregateOrderByWithGroupByField,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
): Record<string, OrderByClause> => {
|
||||
const aggregate = orderByArg.aggregate;
|
||||
|
||||
@@ -359,7 +358,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
|
||||
const aggregateExpression = ProcessAggregateHelper.getAggregateExpression(
|
||||
aggregateField,
|
||||
flatObjectMetadata.nameSingular,
|
||||
this.objectAlias,
|
||||
);
|
||||
|
||||
if (!isDefined(aggregateExpression)) {
|
||||
@@ -379,12 +378,10 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
private parseObjectRecordOrderByForScalarField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForScalarField;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByClause> | null => {
|
||||
const groupByField = groupByFields.find(
|
||||
@@ -404,7 +401,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columnNameWithQuotes = `"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"`;
|
||||
const columnNameWithQuotes = `"${this.objectAlias}"."${fieldMetadata.name}"`;
|
||||
|
||||
const expression = getGroupByOrderExpression({
|
||||
groupByField,
|
||||
@@ -420,12 +417,10 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
private parseObjectRecordOrderByForCompositeField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForCompositeField;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByClause> | null => {
|
||||
const fieldName = Object.keys(orderByArg)[0];
|
||||
@@ -458,7 +453,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
return parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
orderBySubField,
|
||||
flatObjectMetadata.nameSingular,
|
||||
this.objectAlias,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -497,7 +492,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
);
|
||||
}
|
||||
|
||||
const columnNameWithQuotes = `"${
|
||||
const columnNameWithQuotes = `"${this.objectAlias}"."${
|
||||
formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
associatedGroupByField.fieldMetadata.name,
|
||||
associatedGroupByField.subFieldName
|
||||
|
||||
+6
-3
@@ -9,7 +9,11 @@ import {
|
||||
isInputObjectType,
|
||||
} from 'graphql';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined, pascalCase } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
pascalCase,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
|
||||
import { GROUP_BY_DATE_GRANULARITY_INPUT_KEY } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator';
|
||||
@@ -20,7 +24,6 @@ import { type SchemaGenerationContext } from 'src/engine/api/graphql/workspace-s
|
||||
import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util';
|
||||
import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
@@ -63,7 +66,7 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
|
||||
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
|
||||
|
||||
for (const fieldMetadata of fields) {
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
|
||||
if (!isFieldMetadataSupportedInGroupBy(fieldMetadata)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -6,7 +6,10 @@ import {
|
||||
RelationType,
|
||||
type RestrictedFieldsPermissions,
|
||||
} from 'twenty-shared/types';
|
||||
import { isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
import {
|
||||
isFieldMetadataDateKind,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
} from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getAvailableAggregationsFromObjectFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
@@ -15,7 +18,6 @@ import { resolveAggregateFieldKey } from 'src/engine/core-modules/record-crud/ut
|
||||
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
const dateGranularityValues = Object.values(
|
||||
@@ -62,7 +64,7 @@ const buildGroupByEntriesAndDescriptions = (
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(field)) {
|
||||
if (!isFieldMetadataSupportedInGroupBy(field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -3,11 +3,11 @@ import {
|
||||
RelationType,
|
||||
type RestrictedFieldsPermissions,
|
||||
} from 'twenty-shared/types';
|
||||
import { shouldExcludeFieldFromAgentToolSchema } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
|
||||
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
|
||||
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
// Builds the per-field filter shape and full recursive filter schema
|
||||
@@ -28,11 +28,11 @@ export const generateRecordFilterSchema = ({
|
||||
|
||||
objectMetadata.fields.forEach((field) => {
|
||||
if (
|
||||
shouldExcludeFieldFromAgentToolSchema(
|
||||
field,
|
||||
true,
|
||||
shouldExcludeFieldFromAgentToolSchema({
|
||||
fieldName: field.name,
|
||||
isSystem: field.isSystem,
|
||||
additionalExcludedFieldNames,
|
||||
)
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
-89
@@ -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(
|
||||
|
||||
-34
@@ -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);
|
||||
};
|
||||
-21
@@ -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;
|
||||
};
|
||||
+96
@@ -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');
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user