feat(index): support composite unique indexes in create-many upsert conflict resolution (#22604)

## 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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22604?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-07-07 13:54:33 +02:00
committed by GitHub
parent b6e74004d2
commit 54aa52d11c
24 changed files with 853 additions and 90 deletions
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatIndexMaps: FlatEntityMaps<FlatIndexMetadata>;
args: CommonExtendedInput<CreateManyQueryArgs>;
workspaceId: string;
}): Promise<InsertResult> {
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatIndexMaps: FlatEntityMaps<FlatIndexMetadata>;
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,
@@ -4,6 +4,6 @@ export type ConflictingProperty = {
};
export type ConflictingFieldGroup = {
baseField: string;
baseFields: string[];
conflictingProperties: ConflictingProperty[];
};
@@ -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',
@@ -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',
@@ -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<FlatFieldMetadata> & {
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<FlatIndexMetadata> => ({
byUniversalIdentifier: indexes.reduce(
(acc, index) => {
acc[index.universalIdentifier] = index;
return acc;
},
{} as Record<string, FlatIndexMetadata>,
),
universalIdentifierById: indexes.reduce(
(acc, index) => {
acc[index.id] = index.universalIdentifier;
return acc;
},
{} as Record<string, string>,
),
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' },
],
},
]),
);
});
});
@@ -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',
@@ -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<FlatFieldMetadata>;
}):
| { 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<FlatFieldMetadata>,
flatIndexMaps: FlatEntityMaps<FlatIndexMetadata>,
): 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;
};
@@ -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.`,
@@ -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',
@@ -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);
@@ -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);
@@ -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,
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatIndexMaps?: FlatEntityMaps<FlatIndexMetadata>;
objectIdByNameSingular: Record<string, string>;
};
@@ -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,
);
@@ -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<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
flatIndexMaps: FlatEntityMaps<FlatIndexMetadata>,
objectIdByNameSingular: Record<string, string>,
): WorkspaceSchemaBuilderContext => {
const flatObjectMetadata =
@@ -25,6 +27,7 @@ export const buildWorkspaceSchemaBuilderContext = (
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatIndexMaps,
objectIdByNameSingular,
};
};
@@ -25,6 +25,7 @@ export type WorkspaceGraphqlSchemaSDLResult = {
usedScalarNames: string[];
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatIndexMaps: FlatEntityMaps<FlatIndexMetadata>;
};
@Injectable()
@@ -168,6 +169,7 @@ export class WorkspaceGraphqlSchemaSDLService {
usedScalarNames,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatIndexMaps,
};
}
@@ -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<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
flatIndexMaps: FlatEntityMaps<FlatIndexMetadata>,
objectIdByNameSingular: Record<string, string>,
workspaceResolverBuilderMethods: WorkspaceResolverBuilderMethods,
): Promise<IResolvers> {
@@ -113,6 +115,7 @@ export class WorkspaceResolverFactory {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatIndexMaps,
objectIdByNameSingular,
});
}
@@ -144,6 +147,7 @@ export class WorkspaceResolverFactory {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatIndexMaps,
objectIdByNameSingular,
});
}
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatIndexMaps: FlatEntityMaps<FlatIndexMetadata>;
objectIdByNameSingular: Record<string, string>;
}
@@ -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,
);
@@ -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,
};
}
@@ -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,
},
);
@@ -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,
},
);