Common Api - createOne/Many (#15083)
closes https://github.com/twentyhq/core-team-issues/issues/1578
This commit is contained in:
+436
@@ -0,0 +1,436 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, In, InsertResult, ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
|
||||
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
|
||||
import { PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
|
||||
import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util';
|
||||
import { categorizeRecords } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util';
|
||||
import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import {
|
||||
CommonQueryNames,
|
||||
CreateManyQueryArgs,
|
||||
} from 'src/engine/api/common/types/common-query-args.type';
|
||||
import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util';
|
||||
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
|
||||
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
|
||||
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
|
||||
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
|
||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerService {
|
||||
async run({
|
||||
args,
|
||||
authContext: toValidateAuthContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
}: {
|
||||
args: CreateManyQueryArgs;
|
||||
authContext: AuthContext;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
}): Promise<ObjectRecord[]> {
|
||||
const authContext = toValidateAuthContext;
|
||||
|
||||
if (!isWorkspaceAuthContext(authContext)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
'Invalid auth context',
|
||||
CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT,
|
||||
);
|
||||
}
|
||||
assertMutationNotOnRemoteObject(objectMetadataItemWithFieldMaps);
|
||||
|
||||
// TODO : Refacto-common - Remove this validation once https://github.com/twentyhq/core-team-issues/issues/1622 done
|
||||
args.data.forEach((record) => {
|
||||
if (record?.id) {
|
||||
assertIsValidUuid(record.id);
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
workspaceDataSource,
|
||||
repository,
|
||||
roleId,
|
||||
shouldBypassPermissionChecks,
|
||||
} = await this.prepareQueryRunnerContext({
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
const processedArgs = await this.processQueryArgs({
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
args,
|
||||
});
|
||||
|
||||
const objectRecords = await this.insertOrUpsertRecords({
|
||||
repository,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
args: processedArgs,
|
||||
});
|
||||
|
||||
const upsertedRecords = await this.fetchUpsertedRecords({
|
||||
args: processedArgs,
|
||||
objectRecords,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
repository,
|
||||
});
|
||||
|
||||
await this.processNestedRelationsIfNeeded({
|
||||
args: processedArgs,
|
||||
records: upsertedRecords,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
roleId,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
shouldBypassPermissionChecks,
|
||||
});
|
||||
|
||||
return upsertedRecords;
|
||||
}
|
||||
|
||||
async processQueryArgs({
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
args,
|
||||
}: {
|
||||
authContext: WorkspaceAuthContext;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
args: CreateManyQueryArgs;
|
||||
}): Promise<CreateManyQueryArgs> {
|
||||
const hookedArgs =
|
||||
(await this.workspaceQueryHookService.executePreQueryHooks(
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps.nameSingular,
|
||||
CommonQueryNames.createMany,
|
||||
args,
|
||||
//TODO : Refacto-common - To fix when updating workspaceQueryHookService, removing gql typing dependency
|
||||
)) as CreateManyQueryArgs;
|
||||
|
||||
return {
|
||||
...hookedArgs,
|
||||
data: await this.queryRunnerArgsFactory.overrideDataByFieldMetadata({
|
||||
partialRecordInputs: hookedArgs.data,
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async insertOrUpsertRecords({
|
||||
repository,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
args,
|
||||
}: {
|
||||
repository: WorkspaceRepository<ObjectLiteral>;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
args: CreateManyQueryArgs;
|
||||
}): Promise<InsertResult> {
|
||||
if (!args.upsert) {
|
||||
const selectedColumns = buildColumnsToReturn({
|
||||
select: args.selectedFieldsResult.select,
|
||||
relations: args.selectedFieldsResult.relations,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
});
|
||||
|
||||
return await repository.insert(args.data, undefined, selectedColumns);
|
||||
}
|
||||
|
||||
return this.performUpsertOperation({
|
||||
repository,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
private async performUpsertOperation({
|
||||
repository,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
args,
|
||||
}: {
|
||||
repository: WorkspaceRepository<ObjectLiteral>;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
args: CreateManyQueryArgs;
|
||||
}): Promise<InsertResult> {
|
||||
const conflictingFields = getConflictingFields(
|
||||
objectMetadataItemWithFieldMaps,
|
||||
);
|
||||
const existingRecords = await this.findExistingRecords({
|
||||
repository,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
args,
|
||||
conflictingFields,
|
||||
});
|
||||
|
||||
const { recordsToUpdate, recordsToInsert } = categorizeRecords(
|
||||
args.data,
|
||||
conflictingFields,
|
||||
existingRecords,
|
||||
);
|
||||
|
||||
const result: InsertResult = {
|
||||
identifiers: [],
|
||||
generatedMaps: [],
|
||||
raw: [],
|
||||
};
|
||||
|
||||
const columnsToReturn = buildColumnsToReturn({
|
||||
select: args.selectedFieldsResult.select,
|
||||
relations: args.selectedFieldsResult.relations,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
});
|
||||
|
||||
if (recordsToUpdate.length > 0) {
|
||||
await this.processRecordsToUpdate({
|
||||
partialRecordsToUpdate: recordsToUpdate,
|
||||
repository,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
result,
|
||||
columnsToReturn,
|
||||
});
|
||||
}
|
||||
|
||||
await this.processRecordsToInsert({
|
||||
recordsToInsert,
|
||||
repository,
|
||||
result,
|
||||
columnsToReturn,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findExistingRecords({
|
||||
repository,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
args,
|
||||
conflictingFields,
|
||||
}: {
|
||||
repository: WorkspaceRepository<ObjectLiteral>;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
args: CreateManyQueryArgs;
|
||||
conflictingFields: {
|
||||
baseField: string;
|
||||
fullPath: string;
|
||||
column: string;
|
||||
}[];
|
||||
}): Promise<PartialObjectRecordWithId[]> {
|
||||
const queryBuilder = repository.createQueryBuilder(
|
||||
objectMetadataItemWithFieldMaps.nameSingular,
|
||||
);
|
||||
|
||||
const whereConditions = buildWhereConditions(args.data, conflictingFields);
|
||||
|
||||
whereConditions.forEach((condition) => {
|
||||
queryBuilder.orWhere(condition);
|
||||
});
|
||||
|
||||
const restrictedFields =
|
||||
repository.objectRecordsPermissions?.[objectMetadataItemWithFieldMaps.id]
|
||||
?.restrictedFields;
|
||||
|
||||
const selectOptions = getAllSelectableFields({
|
||||
restrictedFields: restrictedFields ?? {},
|
||||
objectMetadata: {
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
|
||||
},
|
||||
});
|
||||
|
||||
return (await queryBuilder
|
||||
.withDeleted()
|
||||
.setFindOptions({
|
||||
select: selectOptions,
|
||||
})
|
||||
.getMany()) as PartialObjectRecordWithId[];
|
||||
}
|
||||
|
||||
private async processRecordsToUpdate({
|
||||
partialRecordsToUpdate,
|
||||
repository,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
result,
|
||||
columnsToReturn,
|
||||
}: {
|
||||
partialRecordsToUpdate: PartialObjectRecordWithId[];
|
||||
repository: WorkspaceRepository<ObjectLiteral>;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
result: InsertResult;
|
||||
columnsToReturn: string[];
|
||||
}): Promise<void> {
|
||||
const partialRecordsToUpdateWithoutCreatedByUpdate =
|
||||
partialRecordsToUpdate.map((record) =>
|
||||
this.getRecordWithoutCreatedBy(record, objectMetadataItemWithFieldMaps),
|
||||
);
|
||||
|
||||
const savedRecords = await repository.updateMany(
|
||||
partialRecordsToUpdateWithoutCreatedByUpdate.map((record) => ({
|
||||
criteria: record.id,
|
||||
partialEntity: { ...record, deletedAt: null },
|
||||
})),
|
||||
undefined,
|
||||
columnsToReturn,
|
||||
);
|
||||
|
||||
result.identifiers.push(
|
||||
...savedRecords.generatedMaps.map((record) => ({ id: record.id })),
|
||||
);
|
||||
result.generatedMaps.push(
|
||||
...savedRecords.generatedMaps.map((record) => ({ id: record.id })),
|
||||
);
|
||||
}
|
||||
|
||||
private async processRecordsToInsert({
|
||||
recordsToInsert,
|
||||
repository,
|
||||
result,
|
||||
columnsToReturn,
|
||||
}: {
|
||||
recordsToInsert: Partial<ObjectRecord>[];
|
||||
repository: WorkspaceRepository<ObjectLiteral>;
|
||||
result: InsertResult;
|
||||
columnsToReturn: string[];
|
||||
}): Promise<void> {
|
||||
if (recordsToInsert.length > 0) {
|
||||
const insertResult = await repository.insert(
|
||||
recordsToInsert,
|
||||
undefined,
|
||||
columnsToReturn,
|
||||
);
|
||||
|
||||
result.identifiers.push(...insertResult.identifiers);
|
||||
result.generatedMaps.push(...insertResult.generatedMaps);
|
||||
result.raw.push(...insertResult.raw);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUpsertedRecords({
|
||||
args,
|
||||
objectRecords,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
repository,
|
||||
}: {
|
||||
args: CreateManyQueryArgs;
|
||||
objectRecords: InsertResult;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
repository: WorkspaceRepository<ObjectLiteral>;
|
||||
}): Promise<ObjectRecord[]> {
|
||||
const queryBuilder = repository.createQueryBuilder(
|
||||
objectMetadataItemWithFieldMaps.nameSingular,
|
||||
);
|
||||
|
||||
const columnsToSelect = buildColumnsToSelect({
|
||||
select: args.selectedFieldsResult.select,
|
||||
relations: args.selectedFieldsResult.relations,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
});
|
||||
|
||||
const upsertedRecords = await queryBuilder
|
||||
.setFindOptions({
|
||||
select: columnsToSelect,
|
||||
})
|
||||
.where({
|
||||
id: In(objectRecords.generatedMaps.map((record) => record.id)),
|
||||
})
|
||||
.withDeleted()
|
||||
.take(QUERY_MAX_RECORDS)
|
||||
.getMany();
|
||||
|
||||
return upsertedRecords as ObjectRecord[];
|
||||
}
|
||||
|
||||
private async processNestedRelationsIfNeeded({
|
||||
args,
|
||||
records,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
roleId,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
shouldBypassPermissionChecks,
|
||||
}: {
|
||||
args: CreateManyQueryArgs;
|
||||
records: ObjectRecord[];
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
roleId?: string;
|
||||
authContext: AuthContext;
|
||||
workspaceDataSource: WorkspaceDataSource;
|
||||
shouldBypassPermissionChecks: boolean;
|
||||
}): Promise<void> {
|
||||
if (!args.selectedFieldsResult.relations) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processNestedRelationsHelper.processNestedRelations({
|
||||
objectMetadataMaps,
|
||||
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
|
||||
parentObjectRecords: records,
|
||||
//TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common
|
||||
relations: args.selectedFieldsResult.relations as Record<
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
roleId,
|
||||
shouldBypassPermissionChecks,
|
||||
selectedFields: args.selectedFieldsResult.select,
|
||||
});
|
||||
}
|
||||
|
||||
private getRecordWithoutCreatedBy(
|
||||
record: PartialObjectRecordWithId,
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
|
||||
): Omit<PartialObjectRecordWithId, 'createdBy'> {
|
||||
let recordWithoutCreatedByUpdate = record;
|
||||
|
||||
const createdByFieldMetadataId =
|
||||
objectMetadataItemWithFieldMaps.fieldIdByName['createdBy'];
|
||||
const createdByFieldMetadata =
|
||||
objectMetadataItemWithFieldMaps.fieldsById[createdByFieldMetadataId];
|
||||
|
||||
if (!isDefined(createdByFieldMetadata)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Missing createdBy field metadata for object ${objectMetadataItemWithFieldMaps.nameSingular}`,
|
||||
CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD,
|
||||
);
|
||||
}
|
||||
|
||||
if ('createdBy' in record && createdByFieldMetadata.isCustom === false) {
|
||||
const { createdBy: _createdBy, ...recordWithoutCreatedBy } = record;
|
||||
|
||||
recordWithoutCreatedByUpdate = recordWithoutCreatedBy;
|
||||
}
|
||||
|
||||
return recordWithoutCreatedByUpdate;
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
export type PartialObjectRecordWithId = Partial<ObjectRecord> & { id: string };
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util';
|
||||
|
||||
describe('buildWhereConditions', () => {
|
||||
const records: Partial<ObjectRecord>[] = [
|
||||
{
|
||||
id: 'record-1',
|
||||
uniqueText: 'alpha',
|
||||
emailsField: { primaryEmail: 'alpha@example.com' },
|
||||
},
|
||||
{
|
||||
id: 'record-2',
|
||||
uniqueText: 'beta',
|
||||
emailsField: { primaryEmail: 'beta@example.com' },
|
||||
},
|
||||
{
|
||||
id: 'record-3',
|
||||
// uniqueText intentionally missing to validate filtering of undefined
|
||||
emailsField: { primaryEmail: undefined },
|
||||
},
|
||||
];
|
||||
|
||||
it('returns empty array when no conflicting fields provided', () => {
|
||||
const where = buildWhereConditions(records, []);
|
||||
|
||||
expect(where).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds a single where condition for a flat field using all defined values', () => {
|
||||
const where = buildWhereConditions(records, [
|
||||
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
|
||||
]);
|
||||
|
||||
expect(where).toHaveLength(1);
|
||||
const condition = where[0];
|
||||
|
||||
expect(Object.keys(condition)).toEqual(['uniqueText']);
|
||||
|
||||
const operator = condition.uniqueText;
|
||||
|
||||
expect(operator.type.toLowerCase()).toBe('in');
|
||||
expect(operator.value).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('skips adding a condition when all values for a field are undefined', () => {
|
||||
const where = buildWhereConditions(
|
||||
[{ id: '1' }, { id: '2' }],
|
||||
[
|
||||
{
|
||||
baseField: 'uniqueText',
|
||||
fullPath: 'uniqueText',
|
||||
column: 'uniqueText',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(where).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds conditions for nested paths', () => {
|
||||
const where = buildWhereConditions(records, [
|
||||
{
|
||||
baseField: 'emailsField',
|
||||
fullPath: 'emailsField.primaryEmail',
|
||||
column: 'emailsFieldPrimaryEmail',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(where).toHaveLength(1);
|
||||
const condition = where[0];
|
||||
|
||||
expect(Object.keys(condition)).toEqual(['emailsFieldPrimaryEmail']);
|
||||
|
||||
const operator = condition.emailsFieldPrimaryEmail;
|
||||
|
||||
expect(operator.type.toLowerCase()).toBe('in');
|
||||
expect(operator.value).toEqual(['alpha@example.com', 'beta@example.com']);
|
||||
});
|
||||
|
||||
it('builds multiple conditions when multiple conflicting fields are provided', () => {
|
||||
const where = buildWhereConditions(records, [
|
||||
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
|
||||
{
|
||||
baseField: 'emailsField',
|
||||
fullPath: 'emailsField.primaryEmail',
|
||||
column: 'emailsFieldPrimaryEmail',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(where).toHaveLength(2);
|
||||
|
||||
expect(where.map((condition) => Object.keys(condition)[0]).sort()).toEqual([
|
||||
'emailsFieldPrimaryEmail',
|
||||
'uniqueText',
|
||||
]);
|
||||
|
||||
const uniqueTextOperator = where.find((c) => 'uniqueText' in c)?.uniqueText;
|
||||
|
||||
const emailOperator = where.find(
|
||||
(c) => 'emailsFieldPrimaryEmail' in c,
|
||||
)?.emailsFieldPrimaryEmail;
|
||||
|
||||
expect(uniqueTextOperator?.value).toEqual(['alpha', 'beta']);
|
||||
expect(emailOperator?.value).toEqual([
|
||||
'alpha@example.com',
|
||||
'beta@example.com',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
|
||||
import { categorizeRecords } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util';
|
||||
|
||||
describe('categorizeRecords', () => {
|
||||
const conflictingFields = [
|
||||
{ baseField: 'id', fullPath: 'id', column: 'id' },
|
||||
{
|
||||
baseField: 'uniqueText',
|
||||
fullPath: 'uniqueText',
|
||||
column: 'uniqueText',
|
||||
},
|
||||
{
|
||||
baseField: 'emailsField',
|
||||
fullPath: 'emailsField.primaryEmail',
|
||||
column: 'emailsFieldPrimaryEmail',
|
||||
},
|
||||
];
|
||||
|
||||
const existingRecords: PartialObjectRecordWithId[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
uniqueText: 'alpha',
|
||||
emailsField: { primaryEmail: 'alpha@example.com' },
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
uniqueText: 'beta',
|
||||
emailsField: { primaryEmail: 'beta@example.com' },
|
||||
},
|
||||
];
|
||||
|
||||
it('return records to insert only', () => {
|
||||
const records: Partial<ObjectRecord>[] = [
|
||||
{ uniqueText: 'gamma' },
|
||||
{ emailsField: { primaryEmail: 'nobody@example.com' } },
|
||||
];
|
||||
|
||||
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
|
||||
records,
|
||||
conflictingFields,
|
||||
existingRecords,
|
||||
);
|
||||
|
||||
expect(recordsToUpdate).toHaveLength(0);
|
||||
expect(recordsToInsert).toHaveLength(2);
|
||||
expect(recordsToInsert).toEqual(records);
|
||||
});
|
||||
|
||||
it('return records to update only', () => {
|
||||
const records: Partial<ObjectRecord>[] = [
|
||||
{ uniqueText: 'alpha', name: 'Updated A' },
|
||||
{ emailsField: { primaryEmail: 'beta@example.com' }, name: 'Updated B' },
|
||||
];
|
||||
|
||||
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
|
||||
records,
|
||||
conflictingFields,
|
||||
existingRecords,
|
||||
);
|
||||
|
||||
expect(recordsToInsert).toHaveLength(0);
|
||||
expect(recordsToUpdate).toHaveLength(2);
|
||||
|
||||
const ids = recordsToUpdate.map((r) => r.id);
|
||||
|
||||
expect(ids.sort()).toEqual(['r1', 'r2']);
|
||||
|
||||
expect(recordsToUpdate).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'r1',
|
||||
name: 'Updated A',
|
||||
uniqueText: 'alpha',
|
||||
}),
|
||||
expect.objectContaining({ id: 'r2', name: 'Updated B' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('return records to insert and update', () => {
|
||||
const records: Partial<ObjectRecord>[] = [
|
||||
{ uniqueText: 'alpha' },
|
||||
{ uniqueText: 'gamma' },
|
||||
{ emailsField: { primaryEmail: 'beta@example.com' } },
|
||||
];
|
||||
|
||||
const { recordsToInsert, recordsToUpdate } = categorizeRecords(
|
||||
records,
|
||||
conflictingFields,
|
||||
existingRecords,
|
||||
);
|
||||
|
||||
expect(recordsToUpdate).toHaveLength(2);
|
||||
expect(recordsToInsert).toHaveLength(1);
|
||||
|
||||
expect(recordsToInsert[0]).toEqual({ uniqueText: 'gamma' });
|
||||
|
||||
expect(recordsToUpdate).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'r1', uniqueText: 'alpha' }),
|
||||
expect.objectContaining({ id: 'r2' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { objectMetadataMapItemMock } from 'src/engine/api/__mocks__/object-metadata-item.mock';
|
||||
import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util';
|
||||
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
|
||||
|
||||
describe('getConflictingFields', () => {
|
||||
const workspaceId = 'workspaceId';
|
||||
const objectMetadataId = 'objectMetadataId';
|
||||
|
||||
const idField = getMockFieldMetadataEntity({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
id: 'id-field-id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
isUnique: true,
|
||||
});
|
||||
|
||||
const uniqueTextField = getMockFieldMetadataEntity({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
id: 'unique-text-id',
|
||||
name: 'uniqueText',
|
||||
type: FieldMetadataType.TEXT,
|
||||
isUnique: true,
|
||||
});
|
||||
|
||||
const emailsUniqueField = getMockFieldMetadataEntity({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
id: 'emails-unique-id',
|
||||
name: 'emailsField',
|
||||
type: FieldMetadataType.EMAILS,
|
||||
isUnique: true,
|
||||
});
|
||||
|
||||
const phonesNotUniqueField = getMockFieldMetadataEntity({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
id: 'phones-not-unique-id',
|
||||
name: 'phonesField',
|
||||
type: FieldMetadataType.PHONES,
|
||||
isUnique: false,
|
||||
});
|
||||
|
||||
const addressUniqueFieldNoIncludedProp = getMockFieldMetadataEntity({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
id: 'address-unique-id',
|
||||
name: 'addressField',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
isUnique: true,
|
||||
});
|
||||
|
||||
const buildObjectMetadataWithFields = (
|
||||
fields: (typeof idField)[],
|
||||
): ObjectMetadataItemWithFieldMaps => {
|
||||
const fieldsById = fields.reduce<Record<string, typeof idField>>(
|
||||
(acc, field) => {
|
||||
acc[field.id] = field;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
...objectMetadataMapItemMock,
|
||||
fieldsById,
|
||||
fieldIdByName: Object.fromEntries(
|
||||
Object.values(fieldsById).map((f) => [f.name, f.id]),
|
||||
),
|
||||
} as ObjectMetadataItemWithFieldMaps;
|
||||
};
|
||||
|
||||
it('returns id and unique non-composite fields as conflicts', () => {
|
||||
const objectMetadata = buildObjectMetadataWithFields([
|
||||
idField,
|
||||
uniqueTextField,
|
||||
]);
|
||||
|
||||
const result = getConflictingFields(objectMetadata);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ baseField: 'id', fullPath: 'id', column: 'id' },
|
||||
{
|
||||
baseField: 'uniqueText',
|
||||
fullPath: 'uniqueText',
|
||||
column: 'uniqueText',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns composite field with included unique property using full path and computed column', () => {
|
||||
const objectMetadata = buildObjectMetadataWithFields([
|
||||
idField,
|
||||
emailsUniqueField,
|
||||
]);
|
||||
|
||||
const result = getConflictingFields(objectMetadata);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ baseField: 'id', fullPath: 'id', column: 'id' },
|
||||
{
|
||||
baseField: 'emailsField',
|
||||
fullPath: 'emailsField.primaryEmail',
|
||||
column: 'emailsFieldPrimaryEmail',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not include composite fields without included unique property', () => {
|
||||
const objectMetadata = buildObjectMetadataWithFields([
|
||||
idField,
|
||||
addressUniqueFieldNoIncludedProp,
|
||||
]);
|
||||
|
||||
const result = getConflictingFields(objectMetadata);
|
||||
|
||||
expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]);
|
||||
});
|
||||
|
||||
it('ignores non-unique fields', () => {
|
||||
const objectMetadata = buildObjectMetadataWithFields([
|
||||
idField,
|
||||
phonesNotUniqueField,
|
||||
]);
|
||||
|
||||
const result = getConflictingFields(objectMetadata);
|
||||
|
||||
expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]);
|
||||
});
|
||||
});
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
|
||||
import { getMatchingRecordId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util';
|
||||
import { CommonQueryRunnerExceptionCode } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
describe('getMatchingRecordId', () => {
|
||||
const existingRecords: PartialObjectRecordWithId[] = [
|
||||
{
|
||||
id: 'recordId1',
|
||||
uniqueText: 'alpha',
|
||||
emailsField: { primaryEmail: 'alpha@example.com' },
|
||||
},
|
||||
{
|
||||
id: 'recordId2',
|
||||
uniqueText: 'beta',
|
||||
emailsField: { primaryEmail: 'beta@example.com' },
|
||||
},
|
||||
];
|
||||
|
||||
it('returns the matching record id when exactly one field matches one existing record', () => {
|
||||
const record = {
|
||||
emailsField: { primaryEmail: 'alpha@example.com' },
|
||||
};
|
||||
|
||||
const conflictingFields = [
|
||||
{
|
||||
baseField: 'emailsField',
|
||||
fullPath: 'emailsField.primaryEmail',
|
||||
column: 'emailsFieldPrimaryEmail',
|
||||
},
|
||||
];
|
||||
|
||||
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
|
||||
|
||||
expect(id).toBe('recordId1');
|
||||
});
|
||||
|
||||
it('returns undefined when no existing record matches any conflicting field', () => {
|
||||
const record = {
|
||||
emailsField: { primaryEmail: 'nobody@example.com' },
|
||||
};
|
||||
|
||||
const conflictingFields = [
|
||||
{
|
||||
baseField: 'emailsField',
|
||||
fullPath: 'emailsField.primaryEmail',
|
||||
column: 'emailsFieldPrimaryEmail',
|
||||
},
|
||||
];
|
||||
|
||||
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
|
||||
|
||||
expect(id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the matching id if multiple conflicting fields point to the same existing record', () => {
|
||||
const record = {
|
||||
id: 'recordId1',
|
||||
uniqueText: 'alpha',
|
||||
};
|
||||
|
||||
const conflictingFields = [
|
||||
{ baseField: 'id', fullPath: 'id', column: 'id' },
|
||||
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
|
||||
];
|
||||
|
||||
const id = getMatchingRecordId(record, conflictingFields, existingRecords);
|
||||
|
||||
expect(id).toBe('recordId1');
|
||||
});
|
||||
|
||||
it('throws when conflicting fields match different existing records', () => {
|
||||
const record = {
|
||||
uniqueText: 'alpha',
|
||||
emailsField: { primaryEmail: 'beta@example.com' },
|
||||
};
|
||||
|
||||
const conflictingFields = [
|
||||
{ baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' },
|
||||
{
|
||||
baseField: 'emailsField',
|
||||
fullPath: 'emailsField.primaryEmail',
|
||||
column: 'emailsFieldPrimaryEmail',
|
||||
},
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
getMatchingRecordId(record, conflictingFields, existingRecords),
|
||||
).toThrow();
|
||||
|
||||
try {
|
||||
getMatchingRecordId(record, conflictingFields, existingRecords);
|
||||
} catch (error) {
|
||||
expect(error.code).toBe(
|
||||
CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util';
|
||||
|
||||
describe('getValueFromPath', () => {
|
||||
const baseRecord: Partial<ObjectRecord> = {
|
||||
id: 'recordId',
|
||||
name: 'John Doe',
|
||||
parent: { child: 'nested-value', empty: '' },
|
||||
emailsField: { primaryEmail: 'john@example.com' },
|
||||
};
|
||||
|
||||
it('returns direct field value for single-level path', () => {
|
||||
const value = getValueFromPath(baseRecord, 'name');
|
||||
|
||||
expect(value).toBe('John Doe');
|
||||
});
|
||||
|
||||
it('returns nested value for two-level path', () => {
|
||||
const value = getValueFromPath(baseRecord, 'parent.child');
|
||||
|
||||
expect(value).toBe('nested-value');
|
||||
});
|
||||
|
||||
it('returns undefined when parent field does not exist', () => {
|
||||
const value = getValueFromPath(baseRecord, 'missing.child');
|
||||
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when child field does not exist on existing parent', () => {
|
||||
const value = getValueFromPath(baseRecord, 'parent.missing');
|
||||
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type FindOperator, In } from 'typeorm';
|
||||
|
||||
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util';
|
||||
|
||||
export const buildWhereConditions = (
|
||||
records: Partial<ObjectRecord>[],
|
||||
conflictingFields: {
|
||||
baseField: string;
|
||||
fullPath: string;
|
||||
column: string;
|
||||
}[],
|
||||
): Record<string, FindOperator<string>>[] => {
|
||||
const whereConditions: Record<string, FindOperator<string>>[] = [];
|
||||
|
||||
for (const field of conflictingFields) {
|
||||
const fieldValues = records
|
||||
.map((record) => getValueFromPath(record, field.fullPath))
|
||||
.filter(isDefined);
|
||||
|
||||
if (fieldValues.length > 0) {
|
||||
whereConditions.push({ [field.column]: In(fieldValues) });
|
||||
}
|
||||
}
|
||||
|
||||
return whereConditions;
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
|
||||
import { getMatchingRecordId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util';
|
||||
|
||||
export const categorizeRecords = (
|
||||
records: Partial<ObjectRecord>[],
|
||||
conflictingFields: {
|
||||
baseField: string;
|
||||
fullPath: string;
|
||||
column: string;
|
||||
}[],
|
||||
existingRecords: PartialObjectRecordWithId[],
|
||||
): {
|
||||
recordsToUpdate: PartialObjectRecordWithId[];
|
||||
recordsToInsert: Partial<ObjectRecord>[];
|
||||
} => {
|
||||
const recordsToUpdate: PartialObjectRecordWithId[] = [];
|
||||
const recordsToInsert: Partial<ObjectRecord>[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
const matchingRecordId = getMatchingRecordId(
|
||||
record,
|
||||
conflictingFields,
|
||||
existingRecords,
|
||||
);
|
||||
|
||||
if (isDefined(matchingRecordId)) {
|
||||
recordsToUpdate.push({ ...record, id: matchingRecordId });
|
||||
} else {
|
||||
recordsToInsert.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return { recordsToUpdate, recordsToInsert };
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
|
||||
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
||||
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
|
||||
export const getConflictingFields = (
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
|
||||
): {
|
||||
baseField: string;
|
||||
fullPath: string;
|
||||
column: string;
|
||||
}[] => {
|
||||
return Object.values(objectMetadataItemWithFieldMaps.fieldsById)
|
||||
.filter((field) => field.isUnique || field.name === 'id')
|
||||
.flatMap((field) => {
|
||||
const compositeType = compositeTypeDefinitions.get(field.type);
|
||||
|
||||
if (!compositeType) {
|
||||
return [
|
||||
{
|
||||
baseField: field.name,
|
||||
fullPath: field.name,
|
||||
column: field.name,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const property = compositeType.properties.find(
|
||||
(prop) => prop.isIncludedInUniqueConstraint,
|
||||
);
|
||||
|
||||
return property
|
||||
? [
|
||||
{
|
||||
baseField: field.name,
|
||||
fullPath: `${field.name}.${property.name}`,
|
||||
column: `${field.name}${capitalize(property.name)}`,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
};
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
|
||||
import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
export const getMatchingRecordId = (
|
||||
record: Partial<ObjectRecord>,
|
||||
conflictingFields: {
|
||||
baseField: string;
|
||||
fullPath: string;
|
||||
column: string;
|
||||
}[],
|
||||
existingRecords: PartialObjectRecordWithId[],
|
||||
): string | undefined => {
|
||||
const matchingRecordIds = conflictingFields.reduce<string[]>((acc, field) => {
|
||||
const requestFieldValue = getValueFromPath(record, field.fullPath);
|
||||
|
||||
const matchingRecord = existingRecords.find((existingRecord) => {
|
||||
const existingFieldValue = getValueFromPath(
|
||||
existingRecord,
|
||||
field.fullPath,
|
||||
);
|
||||
|
||||
return (
|
||||
isDefined(existingFieldValue) &&
|
||||
existingFieldValue === requestFieldValue
|
||||
);
|
||||
});
|
||||
|
||||
if (isDefined(matchingRecord)) {
|
||||
acc.push(matchingRecord.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if ([...new Set(matchingRecordIds)].length > 1) {
|
||||
const conflictingFieldsValues = conflictingFields
|
||||
.map((field) => {
|
||||
const value = getValueFromPath(record, field.fullPath);
|
||||
|
||||
return isDefined(value) ? `${field.fullPath}: ${value}` : undefined;
|
||||
})
|
||||
.filter(isDefined)
|
||||
.join(', ');
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
|
||||
CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT,
|
||||
{
|
||||
userFriendlyMessage: msg`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return matchingRecordIds[0];
|
||||
};
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
export const getValueFromPath = (
|
||||
record: Partial<ObjectRecord>,
|
||||
path: string,
|
||||
): string | undefined => {
|
||||
const pathParts = path.split('.');
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
return record[path];
|
||||
}
|
||||
|
||||
const [parentField, childField] = pathParts;
|
||||
|
||||
return record[parentField]?.[childField];
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
|
||||
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
|
||||
import { CreateOneQueryArgs } from 'src/engine/api/common/types/common-query-args.type';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||
|
||||
@Injectable()
|
||||
export class CommonCreateOneQueryRunnerService extends CommonBaseQueryRunnerService {
|
||||
constructor(
|
||||
private readonly commonCreateManyQueryRunnerService: CommonCreateManyQueryRunnerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run({
|
||||
args,
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
}: {
|
||||
args: CreateOneQueryArgs;
|
||||
authContext: AuthContext;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
}): Promise<ObjectRecord> {
|
||||
const result = await this.commonCreateManyQueryRunnerService.run({
|
||||
args: {
|
||||
...args,
|
||||
data: [args.data],
|
||||
},
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
+4
@@ -1,7 +1,11 @@
|
||||
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
|
||||
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
|
||||
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
|
||||
import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service';
|
||||
|
||||
export const CommonQueryRunners = [
|
||||
CommonFindOneQueryRunnerService,
|
||||
CommonFindManyQueryRunnerService,
|
||||
CommonCreateOneQueryRunnerService,
|
||||
CommonCreateManyQueryRunnerService,
|
||||
];
|
||||
|
||||
+2
@@ -9,4 +9,6 @@ export enum CommonQueryRunnerExceptionCode {
|
||||
ARGS_CONFLICT = 'ARGS_CONFLICT',
|
||||
INVALID_ARGS_FIRST = 'INVALID_ARGS_FIRST',
|
||||
INVALID_ARGS_LAST = 'INVALID_ARGS_LAST',
|
||||
UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT = 'UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT',
|
||||
MISSING_SYSTEM_FIELD = 'MISSING_SYSTEM_FIELD',
|
||||
}
|
||||
|
||||
+3
@@ -20,9 +20,12 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
|
||||
case CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST:
|
||||
case CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST:
|
||||
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
|
||||
case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
|
||||
throw new UserInputError(error);
|
||||
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
|
||||
throw new AuthenticationError(error);
|
||||
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
|
||||
throw error;
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
|
||||
+3
@@ -19,11 +19,14 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
|
||||
case CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST:
|
||||
case CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST:
|
||||
case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
|
||||
case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
|
||||
throw new BadRequestException(error.message);
|
||||
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
|
||||
throw new NotFoundException('Record not found');
|
||||
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
|
||||
throw new UnauthorizedException(error.message);
|
||||
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
|
||||
throw error;
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ObjectRecord,
|
||||
type ObjectRecordFilter,
|
||||
type ObjectRecordOrderBy,
|
||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
@@ -8,6 +9,7 @@ import { type CommonSelectedFieldsResult } from 'src/engine/api/common/types/com
|
||||
export enum CommonQueryNames {
|
||||
findOne = 'findOne',
|
||||
findMany = 'findMany',
|
||||
createMany = 'createMany',
|
||||
}
|
||||
|
||||
export interface FindOneQueryArgs {
|
||||
@@ -25,4 +27,14 @@ export interface FindManyQueryArgs {
|
||||
after?: string;
|
||||
}
|
||||
|
||||
export type CommonQueryArgs = FindOneQueryArgs | FindManyQueryArgs;
|
||||
export interface CreateManyQueryArgs {
|
||||
selectedFieldsResult: CommonSelectedFieldsResult;
|
||||
data: Partial<ObjectRecord>[];
|
||||
upsert?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateOneQueryArgs {
|
||||
selectedFieldsResult: CommonSelectedFieldsResult;
|
||||
data: Partial<ObjectRecord>;
|
||||
upsert?: boolean;
|
||||
}
|
||||
|
||||
+48
-35
@@ -18,6 +18,7 @@ import {
|
||||
type UpdateOneResolverArgs,
|
||||
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
@@ -39,35 +40,40 @@ export class QueryRunnerArgsFactory {
|
||||
const fieldMetadataMapByNameByName =
|
||||
options.objectMetadataItemWithFieldMaps.fieldsById;
|
||||
|
||||
const { objectMetadataItemWithFieldMaps, authContext } = options;
|
||||
|
||||
switch (resolverArgsType) {
|
||||
case ResolverArgsType.CreateOne:
|
||||
return {
|
||||
...args,
|
||||
data: (
|
||||
await this.overrideDataByFieldMetadata(
|
||||
[(args as CreateOneResolverArgs).data],
|
||||
options,
|
||||
)
|
||||
await this.overrideDataByFieldMetadata({
|
||||
partialRecordInputs: [(args as CreateOneResolverArgs).data],
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
})
|
||||
)[0],
|
||||
} satisfies CreateOneResolverArgs;
|
||||
case ResolverArgsType.CreateMany:
|
||||
return {
|
||||
...args,
|
||||
data: await this.overrideDataByFieldMetadata(
|
||||
(args as CreateManyResolverArgs).data,
|
||||
options,
|
||||
),
|
||||
data: await this.overrideDataByFieldMetadata({
|
||||
partialRecordInputs: (args as CreateManyResolverArgs).data,
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
}),
|
||||
} satisfies CreateManyResolverArgs;
|
||||
case ResolverArgsType.UpdateOne:
|
||||
return {
|
||||
...args,
|
||||
id: (args as UpdateOneResolverArgs).id,
|
||||
data: (
|
||||
await this.overrideDataByFieldMetadata(
|
||||
[(args as UpdateOneResolverArgs).data],
|
||||
options,
|
||||
false,
|
||||
)
|
||||
await this.overrideDataByFieldMetadata({
|
||||
partialRecordInputs: [(args as UpdateOneResolverArgs).data],
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
shouldBackfillPositionIfUndefined: false,
|
||||
})
|
||||
)[0],
|
||||
} satisfies UpdateOneResolverArgs;
|
||||
case ResolverArgsType.UpdateMany:
|
||||
@@ -78,11 +84,12 @@ export class QueryRunnerArgsFactory {
|
||||
options.objectMetadataItemWithFieldMaps,
|
||||
),
|
||||
data: (
|
||||
await this.overrideDataByFieldMetadata(
|
||||
[(args as UpdateManyResolverArgs).data],
|
||||
options,
|
||||
false,
|
||||
)
|
||||
await this.overrideDataByFieldMetadata({
|
||||
partialRecordInputs: [(args as UpdateManyResolverArgs).data],
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
shouldBackfillPositionIfUndefined: false,
|
||||
})
|
||||
)[0],
|
||||
} satisfies UpdateManyResolverArgs;
|
||||
case ResolverArgsType.FindOne:
|
||||
@@ -115,11 +122,12 @@ export class QueryRunnerArgsFactory {
|
||||
),
|
||||
) ?? [],
|
||||
)) as string[],
|
||||
data: await this.overrideDataByFieldMetadata(
|
||||
(args as FindDuplicatesResolverArgs).data,
|
||||
options,
|
||||
false,
|
||||
),
|
||||
data: await this.overrideDataByFieldMetadata({
|
||||
partialRecordInputs: (args as FindDuplicatesResolverArgs).data,
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
shouldBackfillPositionIfUndefined: false,
|
||||
}),
|
||||
} satisfies FindDuplicatesResolverArgs;
|
||||
case ResolverArgsType.MergeMany:
|
||||
return {
|
||||
@@ -143,18 +151,24 @@ export class QueryRunnerArgsFactory {
|
||||
}
|
||||
}
|
||||
|
||||
private async overrideDataByFieldMetadata(
|
||||
partialRecordInputs: Partial<ObjectRecord>[] | undefined,
|
||||
options: WorkspaceQueryRunnerOptions,
|
||||
async overrideDataByFieldMetadata({
|
||||
partialRecordInputs,
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
shouldBackfillPositionIfUndefined = true,
|
||||
): Promise<Partial<ObjectRecord>[]> {
|
||||
}: {
|
||||
partialRecordInputs: Partial<ObjectRecord>[] | undefined;
|
||||
authContext: AuthContext;
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
|
||||
shouldBackfillPositionIfUndefined?: boolean;
|
||||
}): Promise<Partial<ObjectRecord>[]> {
|
||||
if (!isDefined(partialRecordInputs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allOverriddenRecords: Partial<ObjectRecord>[] = [];
|
||||
|
||||
const workspace = options.authContext.workspace;
|
||||
const workspace = authContext.workspace;
|
||||
|
||||
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
|
||||
|
||||
@@ -163,9 +177,9 @@ export class QueryRunnerArgsFactory {
|
||||
partialRecordInputs,
|
||||
workspaceId: workspace.id,
|
||||
objectMetadata: {
|
||||
isCustom: options.objectMetadataItemWithFieldMaps.isCustom,
|
||||
nameSingular: options.objectMetadataItemWithFieldMaps.nameSingular,
|
||||
fieldIdByName: options.objectMetadataItemWithFieldMaps.fieldIdByName,
|
||||
isCustom: objectMetadataItemWithFieldMaps.isCustom,
|
||||
nameSingular: objectMetadataItemWithFieldMaps.nameSingular,
|
||||
fieldIdByName: objectMetadataItemWithFieldMaps.fieldIdByName,
|
||||
},
|
||||
shouldBackfillPositionIfUndefined,
|
||||
});
|
||||
@@ -175,9 +189,9 @@ export class QueryRunnerArgsFactory {
|
||||
const createArgByArgKey: [string, any][] = await Promise.all(
|
||||
Object.entries(record).map(async ([key, value]) => {
|
||||
const fieldMetadataId =
|
||||
options.objectMetadataItemWithFieldMaps.fieldIdByName[key];
|
||||
objectMetadataItemWithFieldMaps.fieldIdByName[key];
|
||||
const fieldMetadata =
|
||||
options.objectMetadataItemWithFieldMaps.fieldsById[fieldMetadataId];
|
||||
objectMetadataItemWithFieldMaps.fieldsById[fieldMetadataId];
|
||||
|
||||
if (!fieldMetadata) {
|
||||
return [key, value];
|
||||
@@ -193,8 +207,7 @@ export class QueryRunnerArgsFactory {
|
||||
const transformedRecord =
|
||||
await this.recordInputTransformerService.process({
|
||||
recordInput: { [key]: value },
|
||||
objectMetadataMapItem:
|
||||
options.objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
return [key, transformedRecord[key]];
|
||||
|
||||
+58
@@ -1,5 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import graphqlFields from 'graphql-fields';
|
||||
|
||||
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
|
||||
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
|
||||
import {
|
||||
@@ -8,8 +11,14 @@ import {
|
||||
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
|
||||
|
||||
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
|
||||
import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
|
||||
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
|
||||
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
|
||||
@Injectable()
|
||||
export class CreateManyResolverFactory
|
||||
@@ -19,6 +28,8 @@ export class CreateManyResolverFactory
|
||||
|
||||
constructor(
|
||||
private readonly graphqlQueryRunnerService: GraphqlQueryCreateManyResolverService,
|
||||
private readonly commonCreateManyQueryRunnerService: CommonCreateManyQueryRunnerService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {}
|
||||
|
||||
create(
|
||||
@@ -27,6 +38,53 @@ export class CreateManyResolverFactory
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, _context, info) => {
|
||||
const workspaceDataSource =
|
||||
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
|
||||
workspaceId: internalContext.authContext.workspace?.id as string,
|
||||
});
|
||||
|
||||
const featureFlagsMap = workspaceDataSource.featureFlagMap;
|
||||
|
||||
if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) {
|
||||
const graphqlQueryParser = new GraphqlQueryParser(
|
||||
internalContext.objectMetadataItemWithFieldMaps,
|
||||
internalContext.objectMetadataMaps,
|
||||
);
|
||||
|
||||
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
|
||||
internalContext.objectMetadataItemWithFieldMaps,
|
||||
graphqlFields(info),
|
||||
internalContext.objectMetadataMaps,
|
||||
);
|
||||
|
||||
try {
|
||||
const records = await this.commonCreateManyQueryRunnerService.run({
|
||||
args: { ...args, selectedFieldsResult },
|
||||
authContext: internalContext.authContext,
|
||||
objectMetadataMaps: internalContext.objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps:
|
||||
internalContext.objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
const typeORMObjectRecordsParser =
|
||||
new ObjectRecordsToGraphqlConnectionHelper(
|
||||
internalContext.objectMetadataMaps,
|
||||
);
|
||||
|
||||
return records.map((record: ObjectRecord) =>
|
||||
typeORMObjectRecordsParser.processRecord({
|
||||
objectRecord: record,
|
||||
objectName:
|
||||
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
|
||||
take: 1,
|
||||
totalCount: 1,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
return workspaceQueryRunnerGraphqlApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
const options: WorkspaceQueryRunnerOptions = {
|
||||
authContext: internalContext.authContext,
|
||||
info,
|
||||
|
||||
+53
@@ -1,5 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import graphqlFields from 'graphql-fields';
|
||||
|
||||
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
|
||||
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
|
||||
import {
|
||||
@@ -8,8 +10,14 @@ import {
|
||||
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
|
||||
|
||||
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
|
||||
import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service';
|
||||
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
|
||||
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
@Injectable()
|
||||
export class CreateOneResolverFactory
|
||||
@@ -19,6 +27,8 @@ export class CreateOneResolverFactory
|
||||
|
||||
constructor(
|
||||
private readonly graphqlQueryRunnerService: GraphqlQueryCreateOneResolverService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly commonCreateOneQueryRunnerService: CommonCreateOneQueryRunnerService,
|
||||
) {}
|
||||
|
||||
create(
|
||||
@@ -27,6 +37,49 @@ export class CreateOneResolverFactory
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, _context, info) => {
|
||||
const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_COMMON_API_ENABLED,
|
||||
internalContext.authContext.workspace?.id as string,
|
||||
);
|
||||
|
||||
if (isCommonApiEnabled) {
|
||||
const graphqlQueryParser = new GraphqlQueryParser(
|
||||
internalContext.objectMetadataItemWithFieldMaps,
|
||||
internalContext.objectMetadataMaps,
|
||||
);
|
||||
|
||||
const selectedFieldsResult = graphqlQueryParser.parseSelectedFields(
|
||||
internalContext.objectMetadataItemWithFieldMaps,
|
||||
graphqlFields(info),
|
||||
internalContext.objectMetadataMaps,
|
||||
);
|
||||
|
||||
try {
|
||||
const record = await this.commonCreateOneQueryRunnerService.run({
|
||||
args: { ...args, selectedFieldsResult },
|
||||
authContext: internalContext.authContext,
|
||||
objectMetadataMaps: internalContext.objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps:
|
||||
internalContext.objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
const typeORMObjectRecordsParser =
|
||||
new ObjectRecordsToGraphqlConnectionHelper(
|
||||
internalContext.objectMetadataMaps,
|
||||
);
|
||||
|
||||
return typeORMObjectRecordsParser.processRecord({
|
||||
objectRecord: record,
|
||||
objectName:
|
||||
internalContext.objectMetadataItemWithFieldMaps.nameSingular,
|
||||
take: 1,
|
||||
totalCount: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
return workspaceQueryRunnerGraphqlApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
const options: WorkspaceQueryRunnerOptions = {
|
||||
authContext: internalContext.authContext,
|
||||
info,
|
||||
|
||||
+58
@@ -7,14 +7,72 @@ import {
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
|
||||
|
||||
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
|
||||
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
|
||||
import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util';
|
||||
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
|
||||
import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util';
|
||||
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
|
||||
|
||||
@Injectable()
|
||||
export class RestApiCreateManyHandler extends RestApiBaseHandler {
|
||||
constructor(
|
||||
private readonly commonCreateManyQueryRunnerService: CommonCreateManyQueryRunnerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async commonHandle(request: AuthenticatedRequest) {
|
||||
try {
|
||||
const { data, depth, upsert } = this.parseRequestArgs(request);
|
||||
|
||||
const {
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
} = await this.buildCommonOptions(request);
|
||||
|
||||
const selectedFieldsResult = await this.computeSelectedFields({
|
||||
depth,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
authContext,
|
||||
});
|
||||
|
||||
const records = await this.commonCreateManyQueryRunnerService.run({
|
||||
args: { data, selectedFieldsResult, upsert },
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
return this.formatRestResponse(
|
||||
records,
|
||||
objectMetadataItemWithFieldMaps.namePlural,
|
||||
);
|
||||
} catch (error) {
|
||||
workspaceQueryRunnerRestApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
private formatRestResponse(
|
||||
records: ObjectRecord[],
|
||||
objectNamePlural: string,
|
||||
) {
|
||||
return { data: { [objectNamePlural]: records } };
|
||||
}
|
||||
|
||||
private parseRequestArgs(request: AuthenticatedRequest) {
|
||||
return {
|
||||
data: request.body,
|
||||
depth: parseDepthRestRequest(request),
|
||||
upsert: parseUpsertRestRequest(request),
|
||||
};
|
||||
}
|
||||
|
||||
async handle(request: AuthenticatedRequest) {
|
||||
const { objectMetadata, repository, restrictedFields } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
+55
@@ -7,14 +7,69 @@ import {
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
|
||||
|
||||
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
|
||||
import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util';
|
||||
import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util';
|
||||
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
|
||||
import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util';
|
||||
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
|
||||
|
||||
@Injectable()
|
||||
export class RestApiCreateOneHandler extends RestApiBaseHandler {
|
||||
constructor(
|
||||
private readonly commonCreateOneQueryRunnerService: CommonCreateOneQueryRunnerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async commonHandle(request: AuthenticatedRequest) {
|
||||
try {
|
||||
const { data, depth, upsert } = this.parseRequestArgs(request);
|
||||
|
||||
const {
|
||||
authContext,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
} = await this.buildCommonOptions(request);
|
||||
|
||||
const selectedFieldsResult = await this.computeSelectedFields({
|
||||
depth,
|
||||
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
authContext,
|
||||
});
|
||||
|
||||
const record = await this.commonCreateOneQueryRunnerService.run({
|
||||
args: { data, selectedFieldsResult, upsert },
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
return this.formatRestResponse(
|
||||
record,
|
||||
objectMetadataItemWithFieldMaps.nameSingular,
|
||||
);
|
||||
} catch (error) {
|
||||
workspaceQueryRunnerRestApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
private formatRestResponse(record: ObjectRecord, objectNameSingular: string) {
|
||||
return { data: { [objectNameSingular]: record } };
|
||||
}
|
||||
|
||||
private parseRequestArgs(request: AuthenticatedRequest) {
|
||||
return {
|
||||
data: request.body,
|
||||
depth: parseDepthRestRequest(request),
|
||||
upsert: parseUpsertRestRequest(request),
|
||||
};
|
||||
}
|
||||
|
||||
async handle(request: AuthenticatedRequest) {
|
||||
const { objectMetadata, repository, restrictedFields } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
|
||||
objectMetadataItemWithFieldMaps.nameSingular,
|
||||
);
|
||||
} catch (error) {
|
||||
return workspaceQueryRunnerRestApiExceptionHandler(error);
|
||||
workspaceQueryRunnerRestApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,23 @@ export class RestApiCoreService {
|
||||
}
|
||||
|
||||
async createOne(request: AuthenticatedRequest) {
|
||||
return await this.restApiCreateOneHandler.handle(request);
|
||||
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
|
||||
|
||||
if (isCommonApiEnabled) {
|
||||
return await this.restApiCreateOneHandler.commonHandle(request);
|
||||
} else {
|
||||
return await this.restApiCreateOneHandler.handle(request);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(request: AuthenticatedRequest) {
|
||||
return await this.restApiCreateManyHandler.handle(request);
|
||||
const isCommonApiEnabled = await this.isCommonApiEnabled(request);
|
||||
|
||||
if (isCommonApiEnabled) {
|
||||
return await this.restApiCreateManyHandler.commonHandle(request);
|
||||
} else {
|
||||
return await this.restApiCreateManyHandler.handle(request);
|
||||
}
|
||||
}
|
||||
|
||||
async findDuplicates(request: AuthenticatedRequest) {
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util';
|
||||
|
||||
describe('parseUpsertRestRequest', () => {
|
||||
it('should return false when upsert query parameter is not defined', () => {
|
||||
const request = {
|
||||
query: {},
|
||||
} as any;
|
||||
|
||||
const result = parseUpsertRestRequest(request);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when upsert query parameter is "true"', () => {
|
||||
const request = {
|
||||
query: {
|
||||
upsert: 'true',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parseUpsertRestRequest(request);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when upsert query parameter is "false"', () => {
|
||||
const request = {
|
||||
query: {
|
||||
upsert: 'false',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parseUpsertRestRequest(request);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when upsert query parameter is empty string', () => {
|
||||
const request = {
|
||||
query: {
|
||||
upsert: '',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parseUpsertRestRequest(request);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when upsert query parameter is a boolean true', () => {
|
||||
const request = {
|
||||
query: {
|
||||
upsert: true,
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parseUpsertRestRequest(request);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
|
||||
|
||||
export const parseUpsertRestRequest = (
|
||||
request: AuthenticatedRequest,
|
||||
): boolean => {
|
||||
if (!isDefined(request.query.upsert)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return request.query.upsert === 'true';
|
||||
};
|
||||
+1
-2
@@ -62,8 +62,7 @@ describe('computeParameters', () => {
|
||||
in: 'query',
|
||||
description: `Determines the level of nested related objects to include in the response.
|
||||
- 0: Primary object only
|
||||
- 1: Primary object + direct relations
|
||||
- 2: Primary object + direct relations + nested relations`,
|
||||
- 1: Primary object + direct relations`,
|
||||
required: false,
|
||||
schema: {
|
||||
type: 'integer',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
computeLimitParameters,
|
||||
computeOrderByParameters,
|
||||
computeStartingAfterParameters,
|
||||
computeUpsertParameters,
|
||||
} from 'src/engine/core-modules/open-api/utils/parameters.utils';
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
|
||||
@@ -208,6 +209,7 @@ export const computeParameterComponents = (
|
||||
endingBefore: computeEndingBeforeParameters(),
|
||||
filter: computeFilterParameters(),
|
||||
depth: computeDepthParameters(),
|
||||
upsert: computeUpsertParameters(),
|
||||
orderBy: computeOrderByParameters(),
|
||||
limit: computeLimitParameters(fromMetadata),
|
||||
};
|
||||
|
||||
@@ -51,8 +51,7 @@ export const computeDepthParameters = (): OpenAPIV3_1.ParameterObject => {
|
||||
in: 'query',
|
||||
description: `Determines the level of nested related objects to include in the response.
|
||||
- 0: Primary object only
|
||||
- 1: Primary object + direct relations
|
||||
- 2: Primary object + direct relations + nested relations`,
|
||||
- 1: Primary object + direct relations`,
|
||||
required: false,
|
||||
schema: {
|
||||
type: 'integer',
|
||||
@@ -62,6 +61,20 @@ export const computeDepthParameters = (): OpenAPIV3_1.ParameterObject => {
|
||||
};
|
||||
};
|
||||
|
||||
export const computeUpsertParameters = (): OpenAPIV3_1.ParameterObject => {
|
||||
return {
|
||||
name: 'upsert',
|
||||
in: 'query',
|
||||
description:
|
||||
'If true, creates the object or updates it if it already exists.',
|
||||
required: false,
|
||||
schema: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const computeFilterParameters = (): OpenAPIV3_1.ParameterObject => {
|
||||
return {
|
||||
name: 'filter',
|
||||
|
||||
@@ -27,7 +27,10 @@ export const computeBatchPath = (
|
||||
tags: [item.namePlural],
|
||||
summary: `Create Many ${item.namePlural}`,
|
||||
operationId: `createMany${capitalize(item.namePlural)}`,
|
||||
parameters: [{ $ref: '#/components/parameters/depth' }],
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/depth' },
|
||||
{ $ref: '#/components/parameters/upsert' },
|
||||
],
|
||||
requestBody: getArrayRequestBody(capitalize(item.nameSingular)),
|
||||
responses: {
|
||||
'201': getCreateManyResponse201(item),
|
||||
@@ -65,7 +68,10 @@ export const computeManyResultPath = (
|
||||
tags: [item.namePlural],
|
||||
summary: `Create One ${item.nameSingular}`,
|
||||
operationId: `createOne${capitalize(item.nameSingular)}`,
|
||||
parameters: [{ $ref: '#/components/parameters/depth' }],
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/depth' },
|
||||
{ $ref: '#/components/parameters/upsert' },
|
||||
],
|
||||
requestBody: getRequestBody(capitalize(item.nameSingular)),
|
||||
responses: {
|
||||
'201': getCreateOneResponse201(item),
|
||||
|
||||
Reference in New Issue
Block a user