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:
+79
-5
@@ -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', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+9
-65
@@ -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,
|
||||
|
||||
+1
-1
@@ -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) => {
|
||||
|
||||
+135
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user