Added relations to SSE events (#17683)
Fixes https://github.com/twentyhq/core-team-issues/issues/2192 This PR implements what is necessary to re-create the query that we build on the frontend to obtain the returned object record from a mutation, but on the backend, which was only partially implemented for REST API. Usually we want to have relations with only their id and label identifier field to have lighter payloads. In the event we only had depth 0 fields, with this PR we have all events with depth 1 relations. We have depth 2 for many-to-many cases, like updateOne or updateMany result : - Junction tables - Activity target tables
This commit is contained in:
+161
@@ -0,0 +1,161 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getAllSelectableFields } from 'src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util';
|
||||
import { getIsFlatFieldAJoinColumn } from 'src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-join-column.util';
|
||||
import { getIsFlatFieldAJunctionRelationField } from 'src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-junction-relation-field';
|
||||
import { CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
|
||||
import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
|
||||
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
type SelectFields = {
|
||||
[key: string]: boolean | SelectFields;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CommonSelectFieldsHelper {
|
||||
computeFromDepth = ({
|
||||
objectsPermissions,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
depth,
|
||||
onlyUseLabelIdentifierFieldsInRelations = false,
|
||||
recurseIntoJunctionTableRelations = false,
|
||||
}: {
|
||||
objectsPermissions: ObjectsPermissions;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
depth: Depth | undefined;
|
||||
onlyUseLabelIdentifierFieldsInRelations?: boolean;
|
||||
recurseIntoJunctionTableRelations?: boolean;
|
||||
}): CommonSelectedFields => {
|
||||
const restrictedFields =
|
||||
objectsPermissions[flatObjectMetadata.id].restrictedFields;
|
||||
|
||||
const relationsSelectFields = this.getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth,
|
||||
onlyUseLabelIdentifierFieldsInRelations,
|
||||
recurseIntoJunctionTableRelations,
|
||||
});
|
||||
|
||||
const selectableFields = getAllSelectableFields({
|
||||
restrictedFields,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
...selectableFields,
|
||||
...relationsSelectFields,
|
||||
};
|
||||
};
|
||||
|
||||
private getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth,
|
||||
onlyUseLabelIdentifierFieldsInRelations = false,
|
||||
currentDepthLevelIsAJunctionTable = false,
|
||||
recurseIntoJunctionTableRelations = false,
|
||||
}: {
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
objectsPermissions: ObjectsPermissions;
|
||||
depth: Depth | undefined;
|
||||
onlyUseLabelIdentifierFieldsInRelations?: boolean;
|
||||
currentDepthLevelIsAJunctionTable?: boolean;
|
||||
recurseIntoJunctionTableRelations?: boolean;
|
||||
}) {
|
||||
if (!isDefined(depth) || depth === 0) return {};
|
||||
|
||||
let relationsSelectFields: SelectFields = {};
|
||||
|
||||
for (const fieldId of flatObjectMetadata.fieldIds) {
|
||||
const flatField = findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: fieldId,
|
||||
});
|
||||
|
||||
if (!isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentDepthLevelIsAJunctionTable) {
|
||||
const fieldIsJunctionRelation = getIsFlatFieldAJunctionRelationField({
|
||||
flatField,
|
||||
});
|
||||
|
||||
if (!fieldIsJunctionRelation) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const relationTargetObjectMetadata =
|
||||
findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
flatEntityId: flatField.relationTargetObjectMetadataId,
|
||||
});
|
||||
|
||||
const relationFieldSelectFields = getAllSelectableFields({
|
||||
restrictedFields:
|
||||
objectsPermissions[relationTargetObjectMetadata.id].restrictedFields,
|
||||
flatObjectMetadata: relationTargetObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations,
|
||||
});
|
||||
|
||||
if (Object.keys(relationFieldSelectFields).length === 0) continue;
|
||||
|
||||
const flatFieldIsJoinColumn = getIsFlatFieldAJoinColumn({ flatField });
|
||||
|
||||
const isFirstDepthLevel =
|
||||
depth === MAX_DEPTH &&
|
||||
isDefined(flatField.relationTargetObjectMetadataId);
|
||||
|
||||
const shouldRecurseIntoRelation =
|
||||
isFirstDepthLevel ||
|
||||
(flatFieldIsJoinColumn && recurseIntoJunctionTableRelations);
|
||||
|
||||
const nextLevelIsAJunctionTable = flatFieldIsJoinColumn;
|
||||
|
||||
if (shouldRecurseIntoRelation) {
|
||||
const nestedRelationFieldSelectFields =
|
||||
this.getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata: relationTargetObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth: 1,
|
||||
onlyUseLabelIdentifierFieldsInRelations,
|
||||
currentDepthLevelIsAJunctionTable: nextLevelIsAJunctionTable,
|
||||
recurseIntoJunctionTableRelations,
|
||||
});
|
||||
|
||||
relationsSelectFields[flatField.name] = {
|
||||
...relationFieldSelectFields,
|
||||
...nestedRelationFieldSelectFields,
|
||||
};
|
||||
} else {
|
||||
relationsSelectFields[flatField.name] = relationFieldSelectFields;
|
||||
}
|
||||
}
|
||||
|
||||
return relationsSelectFields;
|
||||
}
|
||||
}
|
||||
+645
@@ -0,0 +1,645 @@
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
|
||||
import { getAllSelectableFields } from 'src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
describe('getAllSelectableFields', () => {
|
||||
const createMockField = (
|
||||
overrides: Partial<FlatFieldMetadata> & {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldMetadataType;
|
||||
},
|
||||
): FlatFieldMetadata =>
|
||||
({
|
||||
objectMetadataId: 'object-id',
|
||||
workspaceId: 'workspace-id',
|
||||
isNullable: true,
|
||||
isLabelSyncedWithName: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
universalIdentifier: overrides.id,
|
||||
viewFieldIds: [],
|
||||
viewFilterIds: [],
|
||||
kanbanAggregateOperationViewIds: [],
|
||||
calendarViewIds: [],
|
||||
applicationId: null,
|
||||
label: overrides.name,
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata;
|
||||
|
||||
const buildFlatFieldMetadataMaps = (
|
||||
fields: FlatFieldMetadata[],
|
||||
): FlatEntityMaps<FlatFieldMetadata> => ({
|
||||
byUniversalIdentifier: fields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.universalIdentifier] = field;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, FlatFieldMetadata>,
|
||||
),
|
||||
universalIdentifierById: fields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.id] = field.universalIdentifier;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
universalIdentifiersByApplicationId: {},
|
||||
});
|
||||
|
||||
const buildFlatObjectMetadata = (fieldIds: string[]): FlatObjectMetadata =>
|
||||
({
|
||||
id: 'object-id',
|
||||
workspaceId: 'workspace-id',
|
||||
nameSingular: 'testObject',
|
||||
namePlural: 'testObjects',
|
||||
labelSingular: 'Test Object',
|
||||
labelPlural: 'Test Objects',
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
universalIdentifier: 'object-id',
|
||||
fieldIds: fieldIds,
|
||||
indexMetadataIds: [],
|
||||
viewIds: [],
|
||||
applicationId: null,
|
||||
}) as unknown as FlatObjectMetadata;
|
||||
|
||||
it('should return all fields as selectable when no restrictions', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
email: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return restricted fields', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-2': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create nested objects for composite fields', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'fullName',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
const field3 = createMockField({
|
||||
id: 'field-3',
|
||||
name: 'domainName',
|
||||
type: FieldMetadataType.LINKS,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
field1,
|
||||
field2,
|
||||
field3,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
'field-3',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
fullName: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
domainName: {
|
||||
primaryLinkLabel: true,
|
||||
primaryLinkUrl: true,
|
||||
secondaryLinks: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should restrict all sub-fields when composite field is restricted', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'fullName',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-2': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed regular and composite fields with restrictions', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field3 = createMockField({
|
||||
id: 'field-3',
|
||||
name: 'fullName',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
const field4 = createMockField({
|
||||
id: 'field-4',
|
||||
name: 'address',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
field1,
|
||||
field2,
|
||||
field3,
|
||||
field4,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
'field-3',
|
||||
'field-4',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-2': { canRead: false },
|
||||
'field-4': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
fullName: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('onlyUseLabelIdentifierFields flag', () => {
|
||||
it('should only return label identifier, id, and image identifier fields when flag is true', () => {
|
||||
const idField = createMockField({
|
||||
id: 'field-id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
});
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const emailField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const domainNameField = createMockField({
|
||||
id: 'field-3',
|
||||
name: 'domainName',
|
||||
type: FieldMetadataType.LINKS,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
idField,
|
||||
nameField,
|
||||
emailField,
|
||||
domainNameField,
|
||||
]);
|
||||
const flatObjectMetadata = {
|
||||
...buildFlatObjectMetadata([
|
||||
'field-id',
|
||||
'field-1',
|
||||
'field-2',
|
||||
'field-3',
|
||||
]),
|
||||
labelIdentifierFieldMetadataId: 'field-1',
|
||||
imageIdentifierFieldMetadataId: 'field-3',
|
||||
};
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: true,
|
||||
name: true,
|
||||
domainName: {
|
||||
primaryLinkLabel: true,
|
||||
primaryLinkUrl: true,
|
||||
secondaryLinks: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include domainName for company objects even if not image identifier', () => {
|
||||
const idField = createMockField({
|
||||
id: 'field-id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
});
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const domainNameField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'domainName',
|
||||
type: FieldMetadataType.LINKS,
|
||||
});
|
||||
const emailField = createMockField({
|
||||
id: 'field-3',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
idField,
|
||||
nameField,
|
||||
domainNameField,
|
||||
emailField,
|
||||
]);
|
||||
const flatObjectMetadata = {
|
||||
...buildFlatObjectMetadata([
|
||||
'field-id',
|
||||
'field-1',
|
||||
'field-2',
|
||||
'field-3',
|
||||
]),
|
||||
nameSingular: 'company',
|
||||
labelIdentifierFieldMetadataId: 'field-1',
|
||||
};
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: true,
|
||||
name: true,
|
||||
domainName: {
|
||||
primaryLinkLabel: true,
|
||||
primaryLinkUrl: true,
|
||||
secondaryLinks: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect restrictions even with onlyUseLabelIdentifierFields flag', () => {
|
||||
const idField = createMockField({
|
||||
id: 'field-id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
});
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const emailField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
idField,
|
||||
nameField,
|
||||
emailField,
|
||||
]);
|
||||
const flatObjectMetadata = {
|
||||
...buildFlatObjectMetadata(['field-id', 'field-1', 'field-2']),
|
||||
labelIdentifierFieldMetadataId: 'field-1',
|
||||
};
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-1': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations: true,
|
||||
});
|
||||
|
||||
// Label identifier field is restricted, so it should not be returned
|
||||
// even with the flag
|
||||
expect(result).toEqual({
|
||||
id: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MANY_TO_ONE relation fields', () => {
|
||||
it('should return join column name for MANY_TO_ONE relations', () => {
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const companyRelationField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
joinColumnName: 'companyId',
|
||||
},
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
nameField,
|
||||
companyRelationField,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
companyId: true, // join column name instead of relation name
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return join column if relation field is restricted', () => {
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const companyRelationField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
joinColumnName: 'companyId',
|
||||
},
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
nameField,
|
||||
companyRelationField,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-2': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include ONE_TO_MANY relation fields as regular fields', () => {
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const peopleRelationField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'people',
|
||||
type: FieldMetadataType.RELATION,
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
nameField,
|
||||
peopleRelationField,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
people: true, // ONE_TO_MANY relations are included as regular fields
|
||||
});
|
||||
});
|
||||
|
||||
it('should include MANY_TO_ONE relations without joinColumnName as regular fields', () => {
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const relationFieldWithoutJoinColumn = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
},
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
nameField,
|
||||
relationFieldWithoutJoinColumn,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
company: true, // MANY_TO_ONE without joinColumnName are included as regular fields
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty field list', () => {
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle all fields restricted', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
field1,
|
||||
field2,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-1': { canRead: false },
|
||||
'field-2': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle canRead: true in restrictedFields', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
field1,
|
||||
field2,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-1': { canRead: true, canUpdate: false },
|
||||
'field-2': { canRead: false, canUpdate: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
checkIfFieldIsImageIdentifier,
|
||||
checkIfFieldIsLabelIdentifier,
|
||||
} from 'twenty-shared/metadata';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
RelationType,
|
||||
type RestrictedFieldsPermissions,
|
||||
compositeTypeDefinitions,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
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 { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
type SelectableFieldsStructured = Record<
|
||||
string,
|
||||
boolean | Record<string, boolean>
|
||||
>;
|
||||
|
||||
export const getAllSelectableFields = ({
|
||||
restrictedFields,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations = false,
|
||||
}: {
|
||||
restrictedFields: RestrictedFieldsPermissions;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
onlyUseLabelIdentifierFieldsInRelations?: boolean;
|
||||
}): SelectableFieldsStructured => {
|
||||
const result: SelectableFieldsStructured = {};
|
||||
|
||||
for (const fieldId of flatObjectMetadata.fieldIds) {
|
||||
const flatField = findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: fieldId,
|
||||
});
|
||||
|
||||
if (restrictedFields[flatField.id]?.canRead === false) continue;
|
||||
|
||||
if (onlyUseLabelIdentifierFieldsInRelations) {
|
||||
const fieldIsLabelIdentifier = checkIfFieldIsLabelIdentifier(
|
||||
flatField,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
const fieldIsImageIdentifier = checkIfFieldIsImageIdentifier(
|
||||
flatField,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
const fieldIsIdField = flatField.name === 'id';
|
||||
|
||||
if (
|
||||
!fieldIsLabelIdentifier &&
|
||||
!fieldIsImageIdentifier &&
|
||||
!fieldIsIdField
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(flatField.type)) {
|
||||
const compositeType = compositeTypeDefinitions.get(flatField.type);
|
||||
|
||||
if (!compositeType) {
|
||||
throw new Error(
|
||||
`Composite type definition not found for type: ${flatField.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const compositeFields: Record<string, boolean> = {};
|
||||
|
||||
for (const property of compositeType.properties) {
|
||||
compositeFields[property.name] = true;
|
||||
}
|
||||
|
||||
result[flatField.name] = compositeFields;
|
||||
} else if (
|
||||
isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION) &&
|
||||
flatField.settings.relationType === RelationType.MANY_TO_ONE &&
|
||||
isDefined(flatField.settings.joinColumnName)
|
||||
) {
|
||||
result[flatField.settings.joinColumnName] = true;
|
||||
} else {
|
||||
result[flatField.name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const getIsFlatFieldAJoinColumn = ({
|
||||
flatField,
|
||||
}: {
|
||||
flatField: FlatFieldMetadata;
|
||||
}): boolean => {
|
||||
const flatFieldIsJoinColumn =
|
||||
isDefined(flatField.settings) &&
|
||||
'junctionTargetFieldId' in flatField.settings;
|
||||
|
||||
// TODO: refactor this when we remove hard-coded activity relations
|
||||
const flatFieldIsActivityTarget =
|
||||
flatField.name === 'noteTargets' || flatField.name === 'taskTargets';
|
||||
|
||||
return flatFieldIsJoinColumn || flatFieldIsActivityTarget;
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { RelationType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const getIsFlatFieldAJunctionRelationField = ({
|
||||
flatField,
|
||||
}: {
|
||||
flatField: FlatFieldMetadata;
|
||||
}): boolean => {
|
||||
const isJunctionRelationField =
|
||||
isDefined(flatField.settings) &&
|
||||
'relationType' in flatField.settings &&
|
||||
flatField.settings.relationType === RelationType.MANY_TO_ONE &&
|
||||
isDefined(flatField.settings.joinColumnName);
|
||||
|
||||
// TODO: refactor this when we remove hard-coded activity relations
|
||||
const isActivityRelationField =
|
||||
flatField.name === 'note' || flatField.name === 'task';
|
||||
|
||||
return isJunctionRelationField || isActivityRelationField;
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
|
||||
import { type Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const getShouldRecurseIntoRelation = ({
|
||||
depth,
|
||||
flatField,
|
||||
}: {
|
||||
depth: Depth | undefined;
|
||||
flatField: FlatFieldMetadata;
|
||||
}): boolean => {
|
||||
const flatFieldIsJoinColumn =
|
||||
isDefined(flatField.settings) &&
|
||||
'junctionTargetFieldId' in flatField.settings;
|
||||
|
||||
// TODO: refactor this when we remove hard-coded activity relations
|
||||
const flatFieldIsActivityTarget =
|
||||
flatField.name === 'noteTargets' || flatField.name === 'taskTargets';
|
||||
|
||||
const shouldGoOneLevelDeeper =
|
||||
depth === MAX_DEPTH && isDefined(flatField.relationTargetObjectMetadataId);
|
||||
|
||||
const shouldRecurseIntoRelation =
|
||||
shouldGoOneLevelDeeper ||
|
||||
flatFieldIsActivityTarget ||
|
||||
flatFieldIsJoinColumn;
|
||||
|
||||
return shouldRecurseIntoRelation;
|
||||
};
|
||||
Reference in New Issue
Block a user