Connect/Disconnect - Add Disconnect logic + Migration to query builders (insert/update) (#13271)

Context : 
Large PR with 600+ test files. Enable connect and disconnect logic in
createMany (upsert true) / updateOne / updateMany resolvers

- Add disconnect logic
- Gather disconnect and connect logic -> called relation nested queries
- Move logic to query builder (insert and update one) with a preparation
step in .set/.values and an execution step in .execute
- Add integration tests

Test : 
- Test API call on updateMany, updateOne, createMany (upsert:true) with
connect/disconnect
This commit is contained in:
Etienne
2025-07-24 17:04:38 +02:00
committed by GitHub
parent 7bfa003682
commit 88a6913217
20 changed files with 1182 additions and 445 deletions
@@ -160,9 +160,10 @@ describe('computeRelationConnectQueryConfigs', () => {
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
{},
);
expect(result).toEqual({});
expect(result).toEqual([]);
});
it('should throw an error if a connect field is not a relation field', () => {
@@ -176,11 +177,18 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
name: { connect: { where: { name: { lastName: 'Doe' } } } },
},
};
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
}).toThrow('Connect is not allowed for name on person');
});
@@ -195,11 +203,18 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
'company-related-to-1': { connect: { where: { name: 'company1' } } },
},
};
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
}).toThrow(
"Missing required fields: at least one unique constraint have to be fully populated for 'company-related-to-1'.",
@@ -222,11 +237,26 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
'company-related-to-1': {
connect: {
where: {
domainName: { primaryLinkUrl: 'company1.com' },
id: '1',
address: 'company1 address',
},
},
},
},
};
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
}).toThrow(
"Field address is not a unique constraint field for 'company-related-to-1'.",
@@ -255,11 +285,27 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
'company-related-to-1': {
connect: {
where: {
domainName: { primaryLinkUrl: 'company1.com' },
},
},
},
},
'1': {
'company-related-to-1': { connect: { where: { id: '2' } } },
},
};
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
}).toThrow(
'Expected the same constraint fields to be used consistently across all operations for company-related-to-1.',
@@ -298,14 +344,42 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
'company-related-to-1': {
connect: {
where: { domainName: { primaryLinkUrl: 'company.com' } },
},
},
'company-related-to-2': {
connect: {
where: { id: '1' },
},
},
},
'1': {
'company-related-to-1': {
connect: {
where: { domainName: { primaryLinkUrl: 'other-company.com' } },
},
},
'company-related-to-2': {
connect: {
where: { id: '2' },
},
},
},
};
const result = computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
expect(result).toEqual({
'company-related-to-1': {
expect(result).toEqual([
{
connectFieldName: 'company-related-to-1',
recordToConnectConditions: [
[['domainNamePrimaryLinkUrl', 'company.com']],
@@ -326,7 +400,7 @@ describe('computeRelationConnectQueryConfigs', () => {
},
],
},
'company-related-to-2': {
{
connectFieldName: 'company-related-to-2',
recordToConnectConditions: [[['id', '1']], [['id', '2']]],
recordToConnectConditionByEntityIndex: {
@@ -344,6 +418,6 @@ describe('computeRelationConnectQueryConfigs', () => {
},
],
},
});
]);
});
});
@@ -17,7 +17,7 @@ describe('createSqlWhereTupleInClause', () => {
const result = createSqlWhereTupleInClause(conditions, tableName);
expect(result.clause).toBe(
'(table_name.field1, table_name.field2) IN ((:value0_0, :value0_1), (:value1_0, :value1_1))',
'("table_name"."field1", "table_name"."field2") IN ((:value0_0, :value0_1), (:value1_0, :value1_1))',
);
expect(result.parameters).toEqual({
value0_0: 'value1',
@@ -16,6 +16,7 @@ import {
RelationConnectQueryConfig,
UniqueConstraintCondition,
} from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import { RelationConnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
@@ -28,19 +29,19 @@ export const computeRelationConnectQueryConfigs = (
entities: Record<string, unknown>[],
objectMetadata: ObjectMetadataItemWithFieldMaps,
objectMetadataMap: ObjectMetadataMaps,
relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex,
) => {
const allConnectQueryConfigs: Record<string, RelationConnectQueryConfig> = {};
for (const [entityIndex, entity] of entities.entries()) {
const connectFields = extractConnectFields(entity);
const nestedRelationConnectFields =
relationConnectQueryFieldsByEntityIndex[entityIndex];
if (connectFields.length === 0) {
continue;
}
for (const connectField of connectFields) {
const [connectFieldName, connectObject] = Object.entries(connectField)[0];
if (!isDefined(nestedRelationConnectFields)) continue;
for (const [connectFieldName, connectObject] of Object.entries(
nestedRelationConnectFields,
)) {
const {
recordToConnectCondition,
uniqueConstraintFields,
@@ -78,7 +79,7 @@ export const computeRelationConnectQueryConfigs = (
}
}
return allConnectQueryConfigs;
return Object.values(allConnectQueryConfigs);
};
const updateConnectQueryConfigs = (
@@ -177,63 +178,6 @@ const computeRecordToConnectCondition = (
};
};
const extractConnectFields = (
entity: Record<string, unknown>,
): { [connectFieldName: string]: ConnectObject }[] => {
const connectFields: { [entityKey: string]: ConnectObject }[] = [];
for (const [key, value] of Object.entries(entity)) {
if (hasRelationConnect(value)) {
connectFields.push({ [key]: value });
}
}
return connectFields;
};
const hasRelationConnect = (value: unknown): value is ConnectObject => {
if (!isDefined(value) || typeof value !== 'object') {
return false;
}
const obj = value as Record<string, unknown>;
if (!isDefined(obj.connect) || typeof obj.connect !== 'object') {
return false;
}
const connect = obj.connect as Record<string, unknown>;
if (!isDefined(connect.where) || typeof connect.where !== 'object') {
return false;
}
const where = connect.where as Record<string, unknown>;
const whereKeys = Object.keys(where);
if (whereKeys.length === 0) {
return false;
}
return whereKeys.every((key) => {
const whereValue = where[key];
if (typeof whereValue === 'string') {
return true;
}
if (whereValue && typeof whereValue === 'object') {
const subObj = whereValue as Record<string, unknown>;
return Object.values(subObj).every(
(subValue) => typeof subValue === 'string',
);
}
return false;
});
};
const checkUniqueConstraintFullyPopulated = (
objectMetadata: ObjectMetadataItemWithFieldMaps,
connectObject: ConnectObject,
@@ -5,7 +5,7 @@ export const createSqlWhereTupleInClause = (
const fieldNames = conditions[0].map(([field, _]) => field);
const tupleClause = fieldNames
.map((field) => `${tableName}.${field}`)
.map((field) => `"${tableName}"."${field}"`)
.join(', ');
const valuePlaceholders = conditions
.map((_, index) => {
@@ -0,0 +1,135 @@
import { isDefined } from 'class-validator';
import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants';
import {
ConnectObject,
DisconnectObject,
} from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import {
RelationConnectQueryFieldsByEntityIndex,
RelationDisconnectQueryFieldsByEntityIndex,
} from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
const hasRelationConnect = (value: unknown): value is ConnectObject => {
if (!isDefined(value) || typeof value !== 'object') {
return false;
}
const obj = value as Record<string, unknown>;
if (
!isDefined(obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT]) ||
typeof obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT] !== 'object'
) {
return false;
}
const connect = obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT] as Record<
string,
unknown
>;
if (
!isDefined(connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE]) ||
typeof connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE] !== 'object'
) {
return false;
}
const where = connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE] as Record<
string,
unknown
>;
const whereKeys = Object.keys(where);
if (whereKeys.length === 0) {
return false;
}
return whereKeys.every((key) => {
const whereValue = where[key];
if (typeof whereValue === 'string') {
return true;
}
if (whereValue && typeof whereValue === 'object') {
const subObj = whereValue as Record<string, unknown>;
return Object.values(subObj).every(
(subValue) => typeof subValue === 'string',
);
}
return false;
});
};
const hasRelationDisconnect = (value: unknown): value is DisconnectObject => {
if (!isDefined(value) || typeof value !== 'object') return false;
const obj = value as Record<string, unknown>;
if (
!isDefined(obj[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT]) ||
typeof obj[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT] !== 'boolean'
)
return false;
return true;
};
export const extractNestedRelationFieldsByEntityIndex = (
entities: Record<string, unknown>[],
): {
relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex;
relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex;
} => {
const relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex =
{};
const relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex =
{};
for (const [entityIndex, entity] of Object.entries(entities)) {
for (const [key, value] of Object.entries(entity)) {
const hasConnect = hasRelationConnect(value);
const hasDisconnect = hasRelationDisconnect(value);
if (hasConnect && hasDisconnect) {
throw new TwentyORMException(
`Cannot have both connect and disconnect for the same field on ${entity.key}.`,
TwentyORMExceptionCode.CONNECT_NOT_ALLOWED,
);
}
const relationConnectQueryFields =
relationConnectQueryFieldsByEntityIndex?.[entityIndex] || {};
if (hasConnect) {
relationConnectQueryFieldsByEntityIndex[entityIndex] = {
...relationConnectQueryFields,
[key]: value,
};
}
const relationDisconnectQueryFields =
relationDisconnectQueryFieldsByEntityIndex?.[entityIndex] || {};
if (hasDisconnect) {
relationDisconnectQueryFieldsByEntityIndex[entityIndex] = {
...relationDisconnectQueryFields,
[key]: value,
};
}
}
}
return {
relationConnectQueryFieldsByEntityIndex,
relationDisconnectQueryFieldsByEntityIndex,
};
};