From 54aa52d11c9d8800d83b4ba70de00837eced81b4 Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Tue, 7 Jul 2026 13:54:33 +0200
Subject: [PATCH] feat(index): support composite unique indexes in create-many
upsert conflict resolution (#22604)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Context
The `createMany` upsert path resolved conflicts by scanning individual
field
metadata and only treating a field as a conflict target when it was
flagged
`isUnique` (plus the primary `id`). This ignored **composite unique
indexes**
(multi-column unique constraints), so upserting against a multi-field
unique
key never matched an existing row and could either insert a duplicate or
fail.
## What changed
- **Conflict groups are now derived from unique indexes**, not from
per-field
`isUnique` flags. `getConflictingFields` reads the object's index
metadata
(`flatIndexMaps`) and builds one `ConflictingFieldGroup` per unique
index
(the primary `id` remains its own group).
- Each index group correctly expands its fields into DB columns,
handling:
- **Composite field types** — expands to the sub-columns included in the
unique constraint (or a specific sub-field when the index targets one).
- **`MANY_TO_ONE` relation fields** — resolves to the join column name.
- **Scalar fields** — used directly.
- `ConflictingFieldGroup.baseField: string` → **`baseFields:
string[]`**, since
a composite index spans multiple fields.
- **Clearer multi-match error message**: conflicting values are now
grouped per
index (`baseFields (fullPath: value, ...)`, groups joined by `;`) so
it's
obvious which unique key caused the ambiguity when a payload matches
different rows across different indexes.
- `CommonCreateManyQueryRunnerService` now fetches `flatIndexMaps` via
`WorkspaceManyOrAllFlatEntityMapsCacheService` and passes them into
`getConflictingFields`; the cache module is wired into
`CoreCommonApiModule`.
## Tests
- New integration suite
`composite-unique-index-upsert.integration-spec.ts`:
- single composite unique index — insert, update-on-match, and
insert-when-key-differs
- **two independent composite unique indexes** — happy path (single row
matches
both) and failure path (payload matches different rows across the two
indexes → `Multiple records found with the same unique field values` /
`BAD_USER_INPUT`).
- Updated unit specs for `get-conflicting-fields`,
`get-matching-record-id`,
`build-where-conditions`, and `categorize-records` to reflect the
index-driven grouping and the `baseFields[]` shape.
## Test plan
- [ ] `npx nx run twenty-server:test:integration:with-db-reset --
composite-unique-index-upsert`
- [ ] `npx nx test twenty-server -- get-conflicting-fields
get-matching-record-id build-where-conditions categorize-records`
- [ ] Manual: upsert against a composite unique index updates the
matching row instead of inserting a duplicate.
fixes
https://github.com/twentyhq/twenty/issues/22580#issuecomment-4894266699
---
...common-create-many-query-runner.service.ts | 17 +
.../types/conflicting-field-group.type.ts | 2 +-
.../build-where-conditions.util.spec.ts | 10 +-
.../__tests__/categorize-records.util.spec.ts | 6 +-
.../get-conflicting-fields.util.spec.ts | 256 ++++++++++--
.../get-matching-record-id.util.spec.ts | 16 +-
.../utils/get-conflicting-fields.util.ts | 179 +++++++-
.../utils/get-matching-record-id.util.ts | 26 +-
.../errors/common-query-runner.exception.ts | 1 +
...r-to-graphql-api-exception-handler.util.ts | 1 +
...nner-to-rest-api-exception-handler.util.ts | 1 +
.../api/common/core-common-api.module.ts | 2 +
.../common-base-query-runner-context.type.ts | 2 +
.../direct-execution.service.ts | 3 +
...d-workspace-schema-builder-context.util.ts | 3 +
.../workspace-graphql-schema-sdl.service.ts | 2 +
.../workspace-resolver.factory.ts | 4 +
...kspace-schema-builder-context.interface.ts | 2 +
.../api/graphql/workspace-schema.factory.ts | 2 +
.../core/handlers/rest-api-base.handler.ts | 2 +
.../handlers/rest-api-create-many.handler.ts | 2 +
.../handlers/rest-api-create-one.handler.ts | 2 +
.../common-api-context-builder.service.ts | 9 +-
...te-unique-index-upsert.integration-spec.ts | 393 ++++++++++++++++++
24 files changed, 853 insertions(+), 90 deletions(-)
create mode 100644 packages/twenty-server/test/integration/graphql/suites/upsert/composite-unique-index-upsert.integration-spec.ts
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts
index b5b6955799..23fa88084b 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts
@@ -36,6 +36,7 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
+import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource';
@@ -74,14 +75,24 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
workspaceDataSource,
} = queryRunnerContext;
+ if (!isDefined(flatIndexMaps)) {
+ throw new CommonQueryRunnerException(
+ `Missing flatIndexMaps in queryRunnerContext`,
+ CommonQueryRunnerExceptionCode.MISSING_FLAT_INDEX_MAPS,
+ { userFriendlyMessage: STANDARD_ERROR_MESSAGE },
+ );
+ }
+
const objectRecords = await this.insertOrUpsertRecords({
repository,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
args,
workspaceId: authContext.workspace.id,
});
@@ -193,6 +204,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
args,
workspaceId,
}: {
@@ -200,6 +212,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps;
flatFieldMetadataMaps: FlatEntityMaps;
+ flatIndexMaps: FlatEntityMaps;
args: CommonExtendedInput;
workspaceId: string;
}): Promise {
@@ -222,6 +235,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
args,
selectedFieldsResult,
workspaceId,
@@ -233,6 +247,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
args,
selectedFieldsResult,
workspaceId,
@@ -241,6 +256,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps;
flatFieldMetadataMaps: FlatEntityMaps;
+ flatIndexMaps: FlatEntityMaps;
args: CreateManyQueryArgs;
selectedFieldsResult: CommonSelectedFieldsResult;
workspaceId: string;
@@ -248,6 +264,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
const conflictingFieldGroups = getConflictingFields(
flatObjectMetadata,
flatFieldMetadataMaps,
+ flatIndexMaps,
);
const existingRecords = await this.findExistingRecords({
repository,
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts
index 43263204f7..fb2d1741f5 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts
@@ -4,6 +4,6 @@ export type ConflictingProperty = {
};
export type ConflictingFieldGroup = {
- baseField: string;
+ baseFields: string[];
conflictingProperties: ConflictingProperty[];
};
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts
index 058c569c26..814cdc3eb4 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts
@@ -31,7 +31,7 @@ describe('buildWhereConditions', () => {
it('builds a single where condition for a flat field using all defined values', () => {
const groups: ConflictingFieldGroup[] = [
{
- baseField: 'uniqueText',
+ baseFields: ['uniqueText'],
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
@@ -54,7 +54,7 @@ describe('buildWhereConditions', () => {
it('skips adding a condition when all values for a field are undefined', () => {
const groups: ConflictingFieldGroup[] = [
{
- baseField: 'uniqueText',
+ baseFields: ['uniqueText'],
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
@@ -69,7 +69,7 @@ describe('buildWhereConditions', () => {
it('builds conditions for nested paths', () => {
const groups: ConflictingFieldGroup[] = [
{
- baseField: 'emailsField',
+ baseFields: ['emailsField'],
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
@@ -95,13 +95,13 @@ describe('buildWhereConditions', () => {
it('builds multiple conditions when multiple conflicting fields are provided', () => {
const groups: ConflictingFieldGroup[] = [
{
- baseField: 'uniqueText',
+ baseFields: ['uniqueText'],
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
},
{
- baseField: 'emailsField',
+ baseFields: ['emailsField'],
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts
index c266e7caa8..c04a7273d2 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts
@@ -7,15 +7,15 @@ import { categorizeRecords } from 'src/engine/api/common/common-query-runners/co
describe('categorizeRecords', () => {
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
- baseField: 'id',
+ baseFields: ['id'],
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
- baseField: 'uniqueText',
+ baseFields: ['uniqueText'],
conflictingProperties: [{ fullPath: 'uniqueText', column: 'uniqueText' }],
},
{
- baseField: 'emailsField',
+ baseFields: ['emailsField'],
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts
index 90bcea4f10..d204cdaba2 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts
@@ -1,8 +1,9 @@
-import { FieldMetadataType } from 'twenty-shared/types';
+import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
describe('getConflictingFields', () => {
@@ -33,50 +34,93 @@ describe('getConflictingFields', () => {
...overrides,
}) as FlatFieldMetadata;
+ type MockIndexField = {
+ fieldMetadataId: string;
+ subFieldName?: string | null;
+ order?: number;
+ };
+
+ const createMockIndex = ({
+ id,
+ name,
+ isUnique,
+ fields,
+ }: {
+ id: string;
+ name: string;
+ isUnique: boolean;
+ fields: MockIndexField[];
+ }): FlatIndexMetadata =>
+ ({
+ id,
+ universalIdentifier: id,
+ name,
+ objectMetadataId,
+ workspaceId,
+ isUnique,
+ indexWhereClause: null,
+ isCustom: false,
+ applicationId: null,
+ flatIndexFieldMetadatas: fields.map((field, index) => ({
+ id: `${id}-field-${index}`,
+ universalIdentifier: `${id}-field-${index}`,
+ indexMetadataId: id,
+ fieldMetadataId: field.fieldMetadataId,
+ subFieldName: field.subFieldName ?? null,
+ order: field.order ?? index,
+ })),
+ }) as unknown as FlatIndexMetadata;
+
const idField = createMockField({
id: 'id-field-id',
name: 'id',
type: FieldMetadataType.UUID,
- isUnique: true,
});
const uniqueTextField = createMockField({
id: 'unique-text-id',
name: 'uniqueText',
type: FieldMetadataType.TEXT,
- isUnique: true,
});
- const emailsUniqueField = createMockField({
- id: 'emails-unique-id',
+ const otherTextField = createMockField({
+ id: 'other-text-id',
+ name: 'otherText',
+ type: FieldMetadataType.TEXT,
+ });
+
+ const emailsField = createMockField({
+ id: 'emails-id',
name: 'emailsField',
type: FieldMetadataType.EMAILS,
- isUnique: true,
});
- const phonesUniqueField = createMockField({
- id: 'phones-unique-id',
+ const phonesField = createMockField({
+ id: 'phones-id',
name: 'phonesField',
type: FieldMetadataType.PHONES,
- isUnique: true,
});
- const phonesNotUniqueField = createMockField({
- id: 'phones-not-unique-id',
- name: 'phonesField',
- type: FieldMetadataType.PHONES,
- isUnique: false,
- });
-
- const addressUniqueFieldNoIncludedProp = createMockField({
- id: 'address-unique-id',
+ const addressField = createMockField({
+ id: 'address-id',
name: 'addressField',
type: FieldMetadataType.ADDRESS,
- isUnique: true,
+ });
+
+ const companyRelationField = createMockField({
+ id: 'company-relation-id',
+ name: 'company',
+ type: FieldMetadataType.RELATION,
+ settings: { relationType: RelationType.MANY_TO_ONE },
+ } as Partial & {
+ id: string;
+ name: string;
+ type: FieldMetadataType;
});
const buildFlatObjectMetadata = (
fields: FlatFieldMetadata[],
+ indexMetadataIds: string[] = [],
): FlatObjectMetadata =>
({
id: objectMetadataId,
@@ -91,8 +135,8 @@ describe('getConflictingFields', () => {
createdAt: new Date(),
updatedAt: new Date(),
universalIdentifier: objectMetadataId,
- fieldIds: fields.map((f) => f.id),
- indexMetadataIds: [],
+ fieldIds: fields.map((field) => field.id),
+ indexMetadataIds,
viewIds: [],
applicationId: null,
}) as unknown as FlatObjectMetadata;
@@ -119,24 +163,54 @@ describe('getConflictingFields', () => {
universalIdentifiersByApplicationId: {},
});
- it('returns id and unique non-composite fields as conflicts', () => {
+ const buildFlatIndexMaps = (
+ indexes: FlatIndexMetadata[],
+ ): FlatEntityMaps => ({
+ byUniversalIdentifier: indexes.reduce(
+ (acc, index) => {
+ acc[index.universalIdentifier] = index;
+
+ return acc;
+ },
+ {} as Record,
+ ),
+ universalIdentifierById: indexes.reduce(
+ (acc, index) => {
+ acc[index.id] = index.universalIdentifier;
+
+ return acc;
+ },
+ {} as Record,
+ ),
+ universalIdentifiersByApplicationId: {},
+ });
+
+ it('returns id and single-field unique index conflicts', () => {
const fields = [idField, uniqueTextField];
- const flatObjectMetadata = buildFlatObjectMetadata(fields);
+ const index = createMockIndex({
+ id: 'unique-text-index',
+ name: 'uniqueTextUniqueIndex',
+ isUnique: true,
+ fields: [{ fieldMetadataId: uniqueTextField.id }],
+ });
+ const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]);
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields);
+ const flatIndexMaps = buildFlatIndexMaps([index]);
const result = getConflictingFields(
flatObjectMetadata,
flatFieldMetadataMaps,
+ flatIndexMaps,
);
expect(result).toEqual(
expect.arrayContaining([
{
- baseField: 'id',
+ baseFields: ['id'],
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
- baseField: 'uniqueText',
+ baseFields: ['uniqueText'],
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
@@ -146,23 +220,31 @@ describe('getConflictingFields', () => {
});
it('returns composite field with included unique property using full path and computed column', () => {
- const fields = [idField, emailsUniqueField];
- const flatObjectMetadata = buildFlatObjectMetadata(fields);
+ const fields = [idField, emailsField];
+ const index = createMockIndex({
+ id: 'emails-index',
+ name: 'emailsUniqueIndex',
+ isUnique: true,
+ fields: [{ fieldMetadataId: emailsField.id }],
+ });
+ const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]);
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields);
+ const flatIndexMaps = buildFlatIndexMaps([index]);
const result = getConflictingFields(
flatObjectMetadata,
flatFieldMetadataMaps,
+ flatIndexMaps,
);
expect(result).toEqual(
expect.arrayContaining([
{
- baseField: 'id',
+ baseFields: ['id'],
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
- baseField: 'emailsField',
+ baseFields: ['emailsField'],
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
@@ -175,22 +257,30 @@ describe('getConflictingFields', () => {
});
it('returns every included unique property for phone composite fields', () => {
- const fields = [idField, phonesUniqueField];
- const flatObjectMetadata = buildFlatObjectMetadata(fields);
+ const fields = [idField, phonesField];
+ const index = createMockIndex({
+ id: 'phones-index',
+ name: 'phonesUniqueIndex',
+ isUnique: true,
+ fields: [{ fieldMetadataId: phonesField.id }],
+ });
+ const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]);
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields);
+ const flatIndexMaps = buildFlatIndexMaps([index]);
const result = getConflictingFields(
flatObjectMetadata,
flatFieldMetadataMaps,
+ flatIndexMaps,
);
expect(result).toEqual([
{
- baseField: 'id',
+ baseFields: ['id'],
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
- baseField: 'phonesField',
+ baseFields: ['phonesField'],
conflictingProperties: [
{
fullPath: 'phonesField.primaryPhoneNumber',
@@ -209,39 +299,123 @@ describe('getConflictingFields', () => {
]);
});
- it('does not include composite fields without included unique property', () => {
- const fields = [idField, addressUniqueFieldNoIncludedProp];
- const flatObjectMetadata = buildFlatObjectMetadata(fields);
+ it('skips composite unique index without included unique property', () => {
+ const fields = [idField, addressField];
+ const index = createMockIndex({
+ id: 'address-index',
+ name: 'addressUniqueIndex',
+ isUnique: true,
+ fields: [{ fieldMetadataId: addressField.id }],
+ });
+ const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]);
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields);
+ const flatIndexMaps = buildFlatIndexMaps([index]);
const result = getConflictingFields(
flatObjectMetadata,
flatFieldMetadataMaps,
+ flatIndexMaps,
);
expect(result).toEqual([
{
- baseField: 'id',
+ baseFields: ['id'],
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
]);
});
- it('ignores non-unique fields', () => {
- const fields = [idField, phonesNotUniqueField];
- const flatObjectMetadata = buildFlatObjectMetadata(fields);
+ it('ignores non-unique indexes', () => {
+ const fields = [idField, uniqueTextField];
+ const index = createMockIndex({
+ id: 'non-unique-index',
+ name: 'nonUniqueIndex',
+ isUnique: false,
+ fields: [{ fieldMetadataId: uniqueTextField.id }],
+ });
+ const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]);
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields);
+ const flatIndexMaps = buildFlatIndexMaps([index]);
const result = getConflictingFields(
flatObjectMetadata,
flatFieldMetadataMaps,
+ flatIndexMaps,
);
expect(result).toEqual([
{
- baseField: 'id',
+ baseFields: ['id'],
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
]);
});
+
+ it('returns a single group with every column of a composite (multi-field) unique index, ordered', () => {
+ const fields = [idField, uniqueTextField, otherTextField];
+ const index = createMockIndex({
+ id: 'composite-index',
+ name: 'uniqueTextOtherTextUniqueIndex',
+ isUnique: true,
+ fields: [
+ { fieldMetadataId: otherTextField.id, order: 1 },
+ { fieldMetadataId: uniqueTextField.id, order: 0 },
+ ],
+ });
+ const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]);
+ const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields);
+ const flatIndexMaps = buildFlatIndexMaps([index]);
+
+ const result = getConflictingFields(
+ flatObjectMetadata,
+ flatFieldMetadataMaps,
+ flatIndexMaps,
+ );
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ {
+ baseFields: ['uniqueText', 'otherText'],
+ conflictingProperties: [
+ { fullPath: 'uniqueText', column: 'uniqueText' },
+ { fullPath: 'otherText', column: 'otherText' },
+ ],
+ },
+ ]),
+ );
+ });
+
+ it('resolves relation fields to their join column in a composite unique index', () => {
+ const fields = [idField, companyRelationField, uniqueTextField];
+ const index = createMockIndex({
+ id: 'company-text-index',
+ name: 'companyIdUniqueTextUniqueIndex',
+ isUnique: true,
+ fields: [
+ { fieldMetadataId: companyRelationField.id, order: 0 },
+ { fieldMetadataId: uniqueTextField.id, order: 1 },
+ ],
+ });
+ const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]);
+ const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields);
+ const flatIndexMaps = buildFlatIndexMaps([index]);
+
+ const result = getConflictingFields(
+ flatObjectMetadata,
+ flatFieldMetadataMaps,
+ flatIndexMaps,
+ );
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ {
+ baseFields: ['company', 'uniqueText'],
+ conflictingProperties: [
+ { fullPath: 'companyId', column: 'companyId' },
+ { fullPath: 'uniqueText', column: 'uniqueText' },
+ ],
+ },
+ ]),
+ );
+ });
});
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts
index 9ee5687ad7..9a632c166b 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts
@@ -32,7 +32,7 @@ describe('getMatchingRecordId', () => {
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
- baseField: 'emailsField',
+ baseFields: ['emailsField'],
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
@@ -61,7 +61,7 @@ describe('getMatchingRecordId', () => {
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
- baseField: 'phonesField',
+ baseFields: ['phonesField'],
conflictingProperties: [
{
fullPath: 'phonesField.primaryPhoneNumber',
@@ -94,7 +94,7 @@ describe('getMatchingRecordId', () => {
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
- baseField: 'phonesField',
+ baseFields: ['phonesField'],
conflictingProperties: [
{
fullPath: 'phonesField.primaryPhoneNumber',
@@ -124,7 +124,7 @@ describe('getMatchingRecordId', () => {
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
- baseField: 'emailsField',
+ baseFields: ['emailsField'],
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
@@ -151,11 +151,11 @@ describe('getMatchingRecordId', () => {
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
- baseField: 'id',
+ baseFields: ['id'],
conflictingProperties: [{ fullPath: 'id', column: 'id' }],
},
{
- baseField: 'uniqueText',
+ baseFields: ['uniqueText'],
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
@@ -179,13 +179,13 @@ describe('getMatchingRecordId', () => {
const conflictingFieldGroups: ConflictingFieldGroup[] = [
{
- baseField: 'uniqueText',
+ baseFields: ['uniqueText'],
conflictingProperties: [
{ fullPath: 'uniqueText', column: 'uniqueText' },
],
},
{
- baseField: 'emailsField',
+ baseFields: ['emailsField'],
conflictingProperties: [
{
fullPath: 'emailsField.primaryEmail',
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts
index 9d4d9f7005..16fd53e21f 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts
@@ -1,39 +1,172 @@
-import { compositeTypeDefinitions } from 'twenty-shared/types';
-import { capitalize } from 'twenty-shared/utils';
+import { compositeTypeDefinitions, RelationType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
-import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
+import {
+ type ConflictingFieldGroup,
+ type ConflictingProperty,
+} from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
+import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
+import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
+import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
+import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
+import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
+import {
+ type FlatIndexFieldMetadata,
+ type FlatIndexMetadata,
+} from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
+const computeConflictingPropertiesForIndexField = ({
+ flatFieldMetadata,
+ subFieldName,
+}: {
+ flatFieldMetadata: FlatFieldMetadata;
+ subFieldName: string | null;
+}): ConflictingProperty[] | undefined => {
+ if (isMorphOrRelationFlatFieldMetadata(flatFieldMetadata)) {
+ if (flatFieldMetadata.settings?.relationType !== RelationType.MANY_TO_ONE) {
+ return undefined;
+ }
+
+ const joinColumn = computeMorphOrRelationFieldJoinColumnName({
+ name: flatFieldMetadata.name,
+ });
+
+ return [{ fullPath: joinColumn, column: joinColumn }];
+ }
+
+ if (isCompositeFieldMetadataType(flatFieldMetadata.type)) {
+ const compositeType = compositeTypeDefinitions.get(flatFieldMetadata.type);
+
+ if (!isDefined(compositeType)) {
+ return undefined;
+ }
+
+ if (isDefined(subFieldName)) {
+ const property = compositeType.properties.find(
+ (compositeProperty) => compositeProperty.name === subFieldName,
+ );
+
+ if (!isDefined(property)) {
+ return undefined;
+ }
+
+ return [
+ {
+ fullPath: `${flatFieldMetadata.name}.${property.name}`,
+ column: computeCompositeColumnName(
+ { name: flatFieldMetadata.name, type: flatFieldMetadata.type },
+ property,
+ ),
+ },
+ ];
+ }
+
+ return compositeType.properties
+ .filter((property) => property.isIncludedInUniqueConstraint)
+ .map((property) => ({
+ fullPath: `${flatFieldMetadata.name}.${property.name}`,
+ column: computeCompositeColumnName(
+ { name: flatFieldMetadata.name, type: flatFieldMetadata.type },
+ property,
+ ),
+ }));
+ }
+
+ return [{ fullPath: flatFieldMetadata.name, column: flatFieldMetadata.name }];
+};
+
+const computeConflictingPropertiesForIndex = ({
+ flatIndexFieldMetadatas,
+ flatFieldMetadataMaps,
+}: {
+ flatIndexFieldMetadatas: FlatIndexFieldMetadata[];
+ flatFieldMetadataMaps: FlatEntityMaps;
+}):
+ | { baseFields: string[]; conflictingProperties: ConflictingProperty[] }
+ | undefined => {
+ const orderedIndexFields = [...flatIndexFieldMetadatas].sort(
+ (a, b) => a.order - b.order,
+ );
+
+ const baseFields: string[] = [];
+ const conflictingProperties: ConflictingProperty[] = [];
+
+ for (const indexField of orderedIndexFields) {
+ const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
+ flatEntityId: indexField.fieldMetadataId,
+ flatEntityMaps: flatFieldMetadataMaps,
+ });
+
+ if (!isDefined(flatFieldMetadata)) {
+ return undefined;
+ }
+
+ const propertiesForField = computeConflictingPropertiesForIndexField({
+ flatFieldMetadata,
+ subFieldName: indexField.subFieldName,
+ });
+
+ if (!isDefined(propertiesForField) || propertiesForField.length === 0) {
+ return undefined;
+ }
+
+ if (!baseFields.includes(flatFieldMetadata.name)) {
+ baseFields.push(flatFieldMetadata.name);
+ }
+
+ conflictingProperties.push(...propertiesForField);
+ }
+
+ return { baseFields, conflictingProperties };
+};
+
export const getConflictingFields = (
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps,
+ flatIndexMaps: FlatEntityMaps,
): ConflictingFieldGroup[] => {
- return getFlatFieldsFromFlatObjectMetadata(
+ const conflictingFieldGroups: ConflictingFieldGroup[] = [];
+
+ const idField = getFlatFieldsFromFlatObjectMetadata(
flatObjectMetadata,
flatFieldMetadataMaps,
- )
- .filter((field) => field.isUnique || field.name === 'id')
- .map((field) => {
- const compositeType = compositeTypeDefinitions.get(field.type);
+ ).find((field) => field.name === 'id');
- if (!compositeType) {
- return {
- baseField: field.name,
- conflictingProperties: [{ fullPath: field.name, column: field.name }],
- };
- }
+ if (isDefined(idField)) {
+ conflictingFieldGroups.push({
+ baseFields: ['id'],
+ conflictingProperties: [{ fullPath: 'id', column: 'id' }],
+ });
+ }
- const conflictingProperties = compositeType.properties
- .filter((prop) => prop.isIncludedInUniqueConstraint)
- .map((property) => ({
- fullPath: `${field.name}.${property.name}`,
- column: `${field.name}${capitalize(property.name)}`,
- }));
+ const uniqueIndexes = findManyFlatEntityByIdInFlatEntityMaps({
+ flatEntityIds: flatObjectMetadata.indexMetadataIds,
+ flatEntityMaps: flatIndexMaps,
+ }).filter((flatIndexMetadata) => flatIndexMetadata.isUnique);
- return { baseField: field.name, conflictingProperties };
- })
- .filter((group) => group.conflictingProperties.length > 0);
+ for (const flatIndexMetadata of uniqueIndexes) {
+ const indexConflictingFields = computeConflictingPropertiesForIndex({
+ flatIndexFieldMetadatas: flatIndexMetadata.flatIndexFieldMetadatas,
+ flatFieldMetadataMaps,
+ });
+
+ if (
+ !isDefined(indexConflictingFields) ||
+ indexConflictingFields.conflictingProperties.length === 0
+ ) {
+ continue;
+ }
+
+ conflictingFieldGroups.push({
+ baseFields: indexConflictingFields.baseFields,
+ conflictingProperties: indexConflictingFields.conflictingProperties,
+ });
+ }
+
+ return conflictingFieldGroups;
};
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts
index 2f587af82d..e4136d9adb 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts
@@ -50,16 +50,28 @@ export const getMatchingRecordId = (
if ([...new Set(matchingRecordIds)].length > 1) {
const conflictingFieldsValues = conflictingFieldGroups
- .flatMap((group) => group.conflictingProperties)
- .map((conflictingProperty) => {
- const value = getValueFromPath(record, conflictingProperty.fullPath);
+ .map((group) => {
+ const values = group.conflictingProperties
+ .map((conflictingProperty) => {
+ const value = getValueFromPath(
+ record,
+ conflictingProperty.fullPath,
+ );
- return isDefined(value)
- ? `${conflictingProperty.fullPath}: ${value}`
- : undefined;
+ return isDefined(value)
+ ? `${conflictingProperty.fullPath}: ${value}`
+ : undefined;
+ })
+ .filter(isDefined);
+
+ if (values.length === 0) {
+ return undefined;
+ }
+
+ return `${group.baseFields.join(', ')} (${values.join(', ')})`;
})
.filter(isDefined)
- .join(', ');
+ .join('; ');
throw new CommonQueryRunnerException(
`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts
index 402d0a618f..6bba0374c0 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts
@@ -3,6 +3,7 @@ import { type MessageDescriptor } from '@lingui/core';
import { CustomException } from 'src/utils/custom-exception';
export enum CommonQueryRunnerExceptionCode {
+ MISSING_FLAT_INDEX_MAPS = 'MISSING_FLAT_INDEX_MAPS',
RECORD_NOT_FOUND = 'RECORD_NOT_FOUND',
INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT',
INVALID_AUTH_CONTEXT = 'INVALID_AUTH_CONTEXT',
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts
index 6016cca0da..dbbb21a7e9 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts
@@ -33,6 +33,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
throw new UserInputError(error);
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new AuthenticationError(error);
+ case CommonQueryRunnerExceptionCode.MISSING_FLAT_INDEX_MAPS:
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
throw new InternalServerError(error);
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts
index e9a0ffc760..253614b4ef 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts
@@ -34,6 +34,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
throw new NotFoundException('Record not found');
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new UnauthorizedException(error.message);
+ case CommonQueryRunnerExceptionCode.MISSING_FLAT_INDEX_MAPS:
case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD:
case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
throw new InternalServerErrorException(error.message);
diff --git a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts
index 6d0c749731..38ded1050c 100644
--- a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts
+++ b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts
@@ -18,6 +18,7 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
+import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -44,6 +45,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
RecordTransformerModule,
FeatureFlagModule,
WorkspaceCacheModule,
+ WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [
ProcessNestedRelationsHelper,
diff --git a/packages/twenty-server/src/engine/api/common/types/common-base-query-runner-context.type.ts b/packages/twenty-server/src/engine/api/common/types/common-base-query-runner-context.type.ts
index 2c32d0e079..938ffac8f6 100644
--- a/packages/twenty-server/src/engine/api/common/types/common-base-query-runner-context.type.ts
+++ b/packages/twenty-server/src/engine/api/common/types/common-base-query-runner-context.type.ts
@@ -1,6 +1,7 @@
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export type CommonBaseQueryRunnerContext = {
@@ -8,5 +9,6 @@ export type CommonBaseQueryRunnerContext = {
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps;
flatFieldMetadataMaps: FlatEntityMaps;
+ flatIndexMaps?: FlatEntityMaps;
objectIdByNameSingular: Record;
};
diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts
index a591f72974..42e9e7b00a 100644
--- a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts
@@ -206,10 +206,12 @@ export class DirectExecutionService {
graphQLResolverNameMap,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'graphQLResolverNameMap',
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
+ 'flatIndexMaps',
]);
const { idByNameSingular: objectIdByNameSingular } =
@@ -235,6 +237,7 @@ export class DirectExecutionService {
entry,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
);
diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts
index 6b66b7c287..6d5332c0eb 100644
--- a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts
@@ -2,12 +2,14 @@ import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-executi
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const buildWorkspaceSchemaBuilderContext = (
entry: ResolverNameMapEntry,
flatObjectMetadataMaps: FlatEntityMaps,
flatFieldMetadataMaps: FlatEntityMaps,
+ flatIndexMaps: FlatEntityMaps,
objectIdByNameSingular: Record,
): WorkspaceSchemaBuilderContext => {
const flatObjectMetadata =
@@ -25,6 +27,7 @@ export const buildWorkspaceSchemaBuilderContext = (
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
};
};
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts
index 625eeaaa64..cd5d0b5547 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts
@@ -25,6 +25,7 @@ export type WorkspaceGraphqlSchemaSDLResult = {
usedScalarNames: string[];
flatObjectMetadataMaps: FlatEntityMaps;
flatFieldMetadataMaps: FlatEntityMaps;
+ flatIndexMaps: FlatEntityMaps;
};
@Injectable()
@@ -168,6 +169,7 @@ export class WorkspaceGraphqlSchemaSDLService {
usedScalarNames,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
};
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
index 2d622082a1..703ff4ce6d 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
@@ -14,6 +14,7 @@ import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-reso
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
@@ -56,6 +57,7 @@ export class WorkspaceResolverFactory {
async create(
flatObjectMetadataMaps: FlatEntityMaps,
flatFieldMetadataMaps: FlatEntityMaps,
+ flatIndexMaps: FlatEntityMaps,
objectIdByNameSingular: Record,
workspaceResolverBuilderMethods: WorkspaceResolverBuilderMethods,
): Promise {
@@ -113,6 +115,7 @@ export class WorkspaceResolverFactory {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
});
}
@@ -144,6 +147,7 @@ export class WorkspaceResolverFactory {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
});
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts
index 10f8aa2cf2..a9e79ee1df 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts
@@ -1,10 +1,12 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export interface WorkspaceSchemaBuilderContext {
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps;
flatFieldMetadataMaps: FlatEntityMaps;
+ flatIndexMaps: FlatEntityMaps;
objectIdByNameSingular: Record;
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts
index 540fa1c7d2..2934e220ef 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts
@@ -39,6 +39,7 @@ export class WorkspaceSchemaFactory {
usedScalarNames,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
} = schemaSDLResult;
const { idByNameSingular } = buildObjectIdByNameMaps(
@@ -48,6 +49,7 @@ export class WorkspaceSchemaFactory {
const autoGeneratedResolvers = await this.workspaceResolverFactory.create(
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
idByNameSingular,
workspaceResolverBuilderMethodNames,
);
diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts
index 3d81553d2c..5bdb302632 100644
--- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts
+++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts
@@ -159,6 +159,7 @@ export abstract class RestApiBaseHandler {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
} = await this.getObjectMetadata(request, parsedObject);
@@ -169,6 +170,7 @@ export abstract class RestApiBaseHandler {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
};
}
diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts
index c07305b3c6..ecb10569f3 100644
--- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts
+++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts
@@ -26,6 +26,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
} = await this.buildCommonOptions(request);
@@ -45,6 +46,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
},
);
diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts
index 8888e9f61d..5b98e51d28 100644
--- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts
+++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts
@@ -27,6 +27,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
} = await this.buildCommonOptions(request);
@@ -46,6 +47,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular,
},
);
diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts
index 7c1fb819e6..7b4c623df4 100644
--- a/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts
@@ -51,11 +51,15 @@ export class CommonApiContextBuilderService {
}): Promise {
const workspaceId = authContext.workspace.id;
- const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
+ const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatIndexMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
- flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
+ flatMapsKeys: [
+ 'flatObjectMetadataMaps',
+ 'flatFieldMetadataMaps',
+ 'flatIndexMaps',
+ ],
},
);
@@ -107,6 +111,7 @@ export class CommonApiContextBuilderService {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
+ flatIndexMaps,
objectIdByNameSingular: idByNameSingular,
},
selectedFields,
diff --git a/packages/twenty-server/test/integration/graphql/suites/upsert/composite-unique-index-upsert.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/upsert/composite-unique-index-upsert.integration-spec.ts
new file mode 100644
index 0000000000..b4c284aa92
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/upsert/composite-unique-index-upsert.integration-spec.ts
@@ -0,0 +1,393 @@
+import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
+import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
+import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
+import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
+import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
+import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
+import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
+import { type Manifest } from 'twenty-shared/application';
+import { FieldMetadataType } from 'twenty-shared/types';
+import { v4 as uuidv4 } from 'uuid';
+
+// A composite UNIQUE index can't be created through the public index API
+// (createOneIndex forces isUnique: false). Application manifest sync is the only
+// API-driven path that yields one: it runs the same validate/build/run migration
+// internally, provisioning a real composite unique index we can upsert against.
+const TEST_APP_ID = uuidv4();
+const TEST_ROLE_ID = uuidv4();
+const FIRST_FIELD_ID = uuidv4();
+const SECOND_FIELD_ID = uuidv4();
+const PAYLOAD_FIELD_ID = uuidv4();
+const INDEX_ID = uuidv4();
+
+const DUAL_A_FIRST_FIELD_ID = uuidv4();
+const DUAL_A_SECOND_FIELD_ID = uuidv4();
+const DUAL_B_FIRST_FIELD_ID = uuidv4();
+const DUAL_B_SECOND_FIELD_ID = uuidv4();
+const DUAL_PAYLOAD_FIELD_ID = uuidv4();
+const DUAL_INDEX_A_ID = uuidv4();
+const DUAL_INDEX_B_ID = uuidv4();
+
+const OBJECT = buildDefaultObjectManifest({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ nameSingular: 'compositeUpsertRecord',
+ namePlural: 'compositeUpsertRecords',
+ labelSingular: 'Composite Upsert Record',
+ labelPlural: 'Composite Upsert Records',
+ description: 'Object to test composite unique index upsert',
+ icon: 'IconTestRecord',
+ additionalFields: [
+ {
+ universalIdentifier: FIRST_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'firstKey',
+ label: 'First Key',
+ isNullable: true,
+ },
+ {
+ universalIdentifier: SECOND_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'secondKey',
+ label: 'Second Key',
+ isNullable: true,
+ },
+ {
+ universalIdentifier: PAYLOAD_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'payload',
+ label: 'Payload',
+ isNullable: true,
+ },
+ ],
+});
+
+// Second object with TWO independent composite unique indexes
+// (indexA on aFirst+aSecond, indexB on bFirst+bSecond). This lets us exercise
+// upsert conflict resolution when a payload can match different rows through
+// different unique indexes.
+const DUAL_OBJECT = buildDefaultObjectManifest({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ nameSingular: 'dualCompositeUpsertRecord',
+ namePlural: 'dualCompositeUpsertRecords',
+ labelSingular: 'Dual Composite Upsert Record',
+ labelPlural: 'Dual Composite Upsert Records',
+ description: 'Object to test upsert with two composite unique indexes',
+ icon: 'IconTestRecord',
+ additionalFields: [
+ {
+ universalIdentifier: DUAL_A_FIRST_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'aFirstKey',
+ label: 'A First Key',
+ isNullable: true,
+ },
+ {
+ universalIdentifier: DUAL_A_SECOND_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'aSecondKey',
+ label: 'A Second Key',
+ isNullable: true,
+ },
+ {
+ universalIdentifier: DUAL_B_FIRST_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'bFirstKey',
+ label: 'B First Key',
+ isNullable: true,
+ },
+ {
+ universalIdentifier: DUAL_B_SECOND_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'bSecondKey',
+ label: 'B Second Key',
+ isNullable: true,
+ },
+ {
+ universalIdentifier: DUAL_PAYLOAD_FIELD_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'payload',
+ label: 'Payload',
+ isNullable: true,
+ },
+ ],
+});
+
+const manifest: Manifest = buildBaseManifest({
+ appId: TEST_APP_ID,
+ roleId: TEST_ROLE_ID,
+ overrides: {
+ objects: [OBJECT, DUAL_OBJECT],
+ fields: [],
+ indexes: [
+ {
+ universalIdentifier: INDEX_ID,
+ objectUniversalIdentifier: OBJECT.universalIdentifier,
+ isUnique: true,
+ fields: [
+ {
+ universalIdentifier: uuidv4(),
+ fieldUniversalIdentifier: FIRST_FIELD_ID,
+ },
+ {
+ universalIdentifier: uuidv4(),
+ fieldUniversalIdentifier: SECOND_FIELD_ID,
+ },
+ ],
+ },
+ {
+ universalIdentifier: DUAL_INDEX_A_ID,
+ objectUniversalIdentifier: DUAL_OBJECT.universalIdentifier,
+ isUnique: true,
+ fields: [
+ {
+ universalIdentifier: uuidv4(),
+ fieldUniversalIdentifier: DUAL_A_FIRST_FIELD_ID,
+ },
+ {
+ universalIdentifier: uuidv4(),
+ fieldUniversalIdentifier: DUAL_A_SECOND_FIELD_ID,
+ },
+ ],
+ },
+ {
+ universalIdentifier: DUAL_INDEX_B_ID,
+ objectUniversalIdentifier: DUAL_OBJECT.universalIdentifier,
+ isUnique: true,
+ fields: [
+ {
+ universalIdentifier: uuidv4(),
+ fieldUniversalIdentifier: DUAL_B_FIRST_FIELD_ID,
+ },
+ {
+ universalIdentifier: uuidv4(),
+ fieldUniversalIdentifier: DUAL_B_SECOND_FIELD_ID,
+ },
+ ],
+ },
+ ],
+ },
+});
+
+const GQL_FIELDS = `
+ id
+ firstKey
+ secondKey
+ payload
+`;
+
+const upsertRecords = (data: object[], upsert: boolean) =>
+ makeGraphqlAPIRequest(
+ createManyOperationFactory({
+ objectMetadataSingularName: OBJECT.nameSingular,
+ objectMetadataPluralName: OBJECT.namePlural,
+ gqlFields: GQL_FIELDS,
+ data,
+ upsert,
+ }),
+ );
+
+const getRecords = (response: { body: { data: Record } }) =>
+ response.body.data.createCompositeUpsertRecords as {
+ id: string;
+ firstKey: string;
+ secondKey: string;
+ payload: string;
+ }[];
+
+const DUAL_GQL_FIELDS = `
+ id
+ aFirstKey
+ aSecondKey
+ bFirstKey
+ bSecondKey
+ payload
+`;
+
+const upsertDualRecords = (data: object[], upsert: boolean) =>
+ makeGraphqlAPIRequest(
+ createManyOperationFactory({
+ objectMetadataSingularName: DUAL_OBJECT.nameSingular,
+ objectMetadataPluralName: DUAL_OBJECT.namePlural,
+ gqlFields: DUAL_GQL_FIELDS,
+ data,
+ upsert,
+ }),
+ );
+
+const getDualRecords = (response: { body: { data: Record } }) =>
+ response.body.data.createDualCompositeUpsertRecords as {
+ id: string;
+ aFirstKey: string;
+ aSecondKey: string;
+ bFirstKey: string;
+ bSecondKey: string;
+ payload: string;
+ }[];
+
+describe('upsert with composite unique index', () => {
+ beforeAll(async () => {
+ await setupApplicationForSync({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ name: 'Composite Upsert App',
+ description: 'App for composite unique index upsert test',
+ sourcePath: 'test-composite-unique-upsert',
+ });
+
+ await syncApplication({ manifest, expectToFail: false });
+ }, 60000);
+
+ afterAll(async () => {
+ await cleanupApplicationAndAppRegistration({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ });
+ }, 60000);
+
+ it('updates the existing record when the full composite key matches', async () => {
+ const recordId = uuidv4();
+
+ const created = getRecords(
+ await upsertRecords(
+ [{ id: recordId, firstKey: 'A', secondKey: 'B', payload: 'original' }],
+ false,
+ ),
+ )[0];
+
+ expect(created.id).toBe(recordId);
+ expect(created.payload).toBe('original');
+
+
+ const upserted = getRecords(
+ await upsertRecords(
+ [{ firstKey: 'A', secondKey: 'B', payload: 'updated' }],
+ true,
+ ),
+ );
+
+ expect(upserted).toHaveLength(1);
+ expect(upserted[0].id).toBe(recordId);
+ expect(upserted[0].payload).toBe('updated');
+ }, 60000);
+
+ it('inserts a new record when only part of the composite key matches', async () => {
+ const existingId = uuidv4();
+ const newId = uuidv4();
+
+ const created = getRecords(
+ await upsertRecords(
+ [{ id: existingId, firstKey: 'C', secondKey: 'D', payload: 'first' }],
+ false,
+ ),
+ )[0];
+
+ const upserted = getRecords(
+ await upsertRecords(
+ [{ id: newId, firstKey: 'C', secondKey: 'X', payload: 'second' }],
+ true,
+ ),
+ );
+
+ expect(upserted).toHaveLength(1);
+ expect(upserted[0].id).toBe(newId);
+ expect(upserted[0].id).not.toBe(created.id);
+ expect(upserted[0].payload).toBe('second');
+ }, 60000);
+
+ describe('with two composite unique indexes', () => {
+ it('updates the record when a single row matches on both composite indexes', async () => {
+ const recordId = uuidv4();
+
+ const created = getDualRecords(
+ await upsertDualRecords(
+ [
+ {
+ id: recordId,
+ aFirstKey: 'a1',
+ aSecondKey: 'a2',
+ bFirstKey: 'b1',
+ bSecondKey: 'b2',
+ payload: 'original',
+ },
+ ],
+ false,
+ ),
+ )[0];
+
+ expect(created.id).toBe(recordId);
+ expect(created.payload).toBe('original');
+
+ // The payload matches this single row through both composite indexes,
+ // so there is no ambiguity and the existing record is updated in place.
+ const upserted = getDualRecords(
+ await upsertDualRecords(
+ [
+ {
+ aFirstKey: 'a1',
+ aSecondKey: 'a2',
+ bFirstKey: 'b1',
+ bSecondKey: 'b2',
+ payload: 'updated',
+ },
+ ],
+ true,
+ ),
+ );
+
+ expect(upserted).toHaveLength(1);
+ expect(upserted[0].id).toBe(recordId);
+ expect(upserted[0].payload).toBe('updated');
+ }, 60000);
+
+ it('fails when the payload matches different rows across the two composite indexes', async () => {
+ const firstRecordId = uuidv4();
+ const secondRecordId = uuidv4();
+
+ getDualRecords(
+ await upsertDualRecords(
+ [
+ {
+ id: firstRecordId,
+ aFirstKey: 'c1',
+ aSecondKey: 'c2',
+ bFirstKey: 'd1',
+ bSecondKey: 'd2',
+ payload: 'first',
+ },
+ {
+ id: secondRecordId,
+ aFirstKey: 'e1',
+ aSecondKey: 'e2',
+ bFirstKey: 'f1',
+ bSecondKey: 'f2',
+ payload: 'second',
+ },
+ ],
+ false,
+ ),
+ );
+
+ // aFirst/aSecond match the first record via indexA while bFirst/bSecond
+ // match the second record via indexB. Two distinct rows match, so upsert
+ // cannot decide which one to update and must reject the operation.
+ const conflictingResponse = await upsertDualRecords(
+ [
+ {
+ aFirstKey: 'c1',
+ aSecondKey: 'c2',
+ bFirstKey: 'f1',
+ bSecondKey: 'f2',
+ payload: 'conflicting',
+ },
+ ],
+ true,
+ );
+
+ expect(conflictingResponse.body.errors).toBeDefined();
+ expect(conflictingResponse.body.errors[0].message).toContain(
+ 'Multiple records found with the same unique field values',
+ );
+ expect(conflictingResponse.body.errors[0].extensions.code).toBe(
+ 'BAD_USER_INPUT',
+ );
+ }, 60000);
+ });
+});