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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user