Common Api - createOne/Many (#15083)

closes https://github.com/twentyhq/core-team-issues/issues/1578
This commit is contained in:
Etienne
2025-10-15 12:13:07 +00:00
committed by GitHub
parent f65783f900
commit 4ae299973b
31 changed files with 1573 additions and 45 deletions
@@ -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;
}
}
@@ -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 };
@@ -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',
]);
});
});
@@ -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' }),
]),
);
});
});
@@ -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' }]);
});
});
@@ -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,
);
}
});
});
@@ -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();
});
});
@@ -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;
};
@@ -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 };
};
@@ -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)}`,
},
]
: [];
});
};
@@ -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];
};
@@ -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];
};
@@ -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];
}
}
@@ -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,
];
@@ -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',
}
@@ -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);
}
@@ -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;
}
@@ -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]];
@@ -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,
@@ -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,
@@ -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);
@@ -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);
@@ -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) {
@@ -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);
});
});
@@ -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';
};
@@ -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),