Skip to content

Commit 5db99f3

Browse files
committed
add integration test on update one / many + create many upsert
1 parent 48fe95b commit 5db99f3

File tree

7 files changed

+222
-8
lines changed

7 files changed

+222
-8
lines changed

packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
241241

242242
const existingRec = existingRecords.find(
243243
(existingRecord) =>
244+
isDefined(existingRecord[field.column]) &&
244245
existingRecord[field.column] === requestFieldValue,
245246
);
246247

packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export class RelationConnectInputTypeDefinitionFactory {
3838
kind: InputTypeDefinitionKind.Create,
3939
type: fields,
4040
},
41+
{
42+
target,
43+
kind: InputTypeDefinitionKind.Update,
44+
type: fields,
45+
},
4146
];
4247
}
4348

packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ const generateRelationField = <
200200
};
201201

202202
if (
203-
[InputTypeDefinitionKind.Create].includes(
203+
[InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes(
204204
kind as InputTypeDefinitionKind,
205205
) &&
206206
isDefined(fieldMetadata.relationTargetObjectMetadataId)

packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/works
1111

1212
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
1313
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
14+
import { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
15+
import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
16+
import { RelationDisconnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type';
1417
import {
1518
TwentyORMException,
1619
TwentyORMExceptionCode,
1720
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
21+
import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries';
1822
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
1923
import { WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
2024
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
@@ -30,6 +34,10 @@ export class WorkspaceUpdateQueryBuilder<
3034
private shouldBypassPermissionChecks: boolean;
3135
private internalContext: WorkspaceInternalContext;
3236
private authContext?: AuthContext;
37+
private relationNestedQueries: RelationNestedQueries;
38+
private connectConfig: Record<string, RelationConnectQueryConfig>;
39+
private disconnectConfig: RelationDisconnectQueryFieldsByEntityIndex;
40+
3341
constructor(
3442
queryBuilder: UpdateQueryBuilder<T>,
3543
objectRecordsPermissions: ObjectRecordsPermissions,
@@ -42,13 +50,16 @@ export class WorkspaceUpdateQueryBuilder<
4250
this.internalContext = internalContext;
4351
this.shouldBypassPermissionChecks = shouldBypassPermissionChecks;
4452
this.authContext = authContext;
53+
this.relationNestedQueries = new RelationNestedQueries(
54+
this.internalContext,
55+
);
4556
}
4657

4758
override clone(): this {
4859
const clonedQueryBuilder = super.clone();
4960

5061
return new WorkspaceUpdateQueryBuilder(
51-
clonedQueryBuilder,
62+
clonedQueryBuilder as UpdateQueryBuilder<T>,
5263
this.objectRecordsPermissions,
5364
this.internalContext,
5465
this.shouldBypassPermissionChecks,
@@ -85,6 +96,28 @@ export class WorkspaceUpdateQueryBuilder<
8596

8697
const before = await eventSelectQueryBuilder.getMany();
8798

99+
const nestedRelationQueryBuilder = new WorkspaceSelectQueryBuilder(
100+
this as unknown as WorkspaceSelectQueryBuilder<T>,
101+
this.objectRecordsPermissions,
102+
this.internalContext,
103+
this.shouldBypassPermissionChecks,
104+
this.authContext,
105+
);
106+
107+
const updatedValues =
108+
await this.relationNestedQueries.processRelationNestedQueries({
109+
entities: this.expressionMap.valuesSet as
110+
| QueryDeepPartialEntityWithNestedRelationFields<T>
111+
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
112+
relationDisconnectQueryFieldsByEntityIndex: this.disconnectConfig,
113+
relationConnectQueryConfigs: this.connectConfig,
114+
queryBuilder: nestedRelationQueryBuilder,
115+
});
116+
117+
this.expressionMap.valuesSet = Array.isArray(updatedValues)
118+
? updatedValues[0]
119+
: updatedValues;
120+
88121
const formattedBefore = formatResult<T[]>(
89122
before,
90123
objectMetadata,
@@ -116,17 +149,44 @@ export class WorkspaceUpdateQueryBuilder<
116149
};
117150
}
118151

119-
override set(_values: QueryDeepPartialEntity<T>): this {
152+
//tododo : fix all typing issues
153+
override set(
154+
_values:
155+
| QueryDeepPartialEntityWithNestedRelationFields<T>
156+
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
157+
): this;
158+
159+
override set(_values: QueryDeepPartialEntity<T>): this;
160+
161+
override set(
162+
_values:
163+
| QueryDeepPartialEntityWithNestedRelationFields<T>
164+
| QueryDeepPartialEntityWithNestedRelationFields<T>[]
165+
| QueryDeepPartialEntity<T>
166+
| QueryDeepPartialEntity<T>[],
167+
): this {
120168
const mainAliasTarget = this.getMainAliasTarget();
121169

122170
const objectMetadata = getObjectMetadataFromEntityTarget(
123171
mainAliasTarget,
124172
this.internalContext,
125173
);
126174

175+
const extendedValues = _values as
176+
| QueryDeepPartialEntityWithNestedRelationFields<T>
177+
| QueryDeepPartialEntityWithNestedRelationFields<T>[];
178+
const { disconnectConfig, connectConfig } =
179+
this.relationNestedQueries.prepareNestedRelationQueries(
180+
extendedValues,
181+
mainAliasTarget,
182+
);
183+
184+
this.disconnectConfig = disconnectConfig;
185+
this.connectConfig = connectConfig;
186+
127187
const formattedUpdateSet = formatData(_values, objectMetadata);
128188

129-
return super.set(formattedUpdateSet);
189+
return super.set(formattedUpdateSet as QueryDeepPartialEntity<T>);
130190
}
131191

132192
override select(): WorkspaceSelectQueryBuilder<T> {

packages/twenty-server/src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const createSqlWhereTupleInClause = (
55
const fieldNames = conditions[0].map(([field, _]) => field);
66

77
const tupleClause = fieldNames
8-
.map((field) => `${tableName}.${field}`)
8+
.map((field) => `"${tableName}"."${field}"`)
99
.join(', ');
1010
const valuePlaceholders = conditions
1111
.map((_, index) => {

packages/twenty-server/test/integration/graphql/suites/object-generated/relation-connect.integration-spec.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ import {
99
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
1010
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
1111
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
12+
import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util';
13+
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
1214
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
1315

16+
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
17+
1418
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
1519

1620
const PERSON_GQL_FIELDS_WITH_COMPANY = `
1721
id
22+
city
1823
company {
1924
id
2025
}
@@ -71,7 +76,7 @@ describe('relation connect in workspace createOne/createMany resolvers (e2e)',
7176
expect(response.body.data.createPerson.company.id).toBe(TEST_COMPANY_1_ID);
7277
});
7378

74-
it('should connect to other records through a MANY-TO-ONE relation - create Many', async () => {
79+
it('should connect to other records through a MANY-TO-ONE relation - create Many - upsert false', async () => {
7580
const graphqlOperation = createManyOperationFactory({
7681
objectMetadataSingularName: 'person',
7782
objectMetadataPluralName: 'people',
@@ -108,6 +113,146 @@ describe('relation connect in workspace createOne/createMany resolvers (e2e)',
108113
);
109114
});
110115

116+
it('should connect to other records through a MANY-TO-ONE relation - create Many - upsert true', async () => {
117+
const createPersonToUpdateOperation = createOneOperationFactory({
118+
objectMetadataSingularName: 'person',
119+
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
120+
data: {
121+
id: TEST_PERSON_1_ID,
122+
city: 'existing-record',
123+
companyId: TEST_COMPANY_1_ID,
124+
},
125+
});
126+
127+
await makeGraphqlAPIRequest(createPersonToUpdateOperation);
128+
129+
const graphqlOperation = createManyOperationFactory({
130+
objectMetadataSingularName: 'person',
131+
objectMetadataPluralName: 'people',
132+
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
133+
data: [
134+
{
135+
id: TEST_PERSON_1_ID,
136+
company: {
137+
connect: {
138+
where: { domainName: { primaryLinkUrl: 'company2.com' } },
139+
},
140+
},
141+
},
142+
{
143+
id: TEST_PERSON_2_ID,
144+
city: 'new-record',
145+
company: {
146+
connect: {
147+
where: { domainName: { primaryLinkUrl: 'company1.com' } },
148+
},
149+
},
150+
},
151+
],
152+
upsert: true,
153+
});
154+
155+
const response = await makeGraphqlAPIRequest(graphqlOperation);
156+
157+
expect(response.body.data.createPeople).toBeDefined();
158+
expect(response.body.data.createPeople).toHaveLength(2);
159+
160+
const updatedPerson = response.body.data.createPeople.find(
161+
(person: ObjectRecord) => person.id === TEST_PERSON_1_ID,
162+
);
163+
164+
const insertedPerson = response.body.data.createPeople.find(
165+
(person: ObjectRecord) => person.id === TEST_PERSON_2_ID,
166+
);
167+
168+
expect(updatedPerson.company.id).toBe(TEST_COMPANY_2_ID);
169+
expect(updatedPerson.city).toBe('existing-record');
170+
171+
expect(insertedPerson.company.id).toBe(TEST_COMPANY_1_ID);
172+
expect(insertedPerson.city).toBe('new-record');
173+
});
174+
175+
it('should connect to other records through a MANY-TO-ONE relation - update One', async () => {
176+
const createPersonToUpdateOperation = createOneOperationFactory({
177+
objectMetadataSingularName: 'person',
178+
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
179+
data: {
180+
id: TEST_PERSON_1_ID,
181+
city: 'existing-record',
182+
companyId: TEST_COMPANY_1_ID,
183+
},
184+
});
185+
186+
await makeGraphqlAPIRequest(createPersonToUpdateOperation);
187+
188+
const graphqlOperation = updateOneOperationFactory({
189+
objectMetadataSingularName: 'person',
190+
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
191+
recordId: TEST_PERSON_1_ID,
192+
data: {
193+
company: {
194+
connect: {
195+
where: { domainName: { primaryLinkUrl: 'company2.com' } },
196+
},
197+
},
198+
},
199+
});
200+
201+
const response = await makeGraphqlAPIRequest(graphqlOperation);
202+
203+
expect(response.body.data.updatePerson).toBeDefined();
204+
expect(response.body.data.updatePerson.company.id).toBe(TEST_COMPANY_2_ID);
205+
expect(response.body.data.updatePerson.city).toBe('existing-record');
206+
});
207+
208+
it('should connect to other records through a MANY-TO-ONE relation - update Many', async () => {
209+
const createPeopleToUpdateOperation = createManyOperationFactory({
210+
objectMetadataSingularName: 'person',
211+
objectMetadataPluralName: 'people',
212+
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
213+
data: [
214+
{
215+
id: TEST_PERSON_1_ID,
216+
companyId: TEST_COMPANY_1_ID,
217+
},
218+
{
219+
id: TEST_PERSON_2_ID,
220+
},
221+
],
222+
});
223+
224+
await makeGraphqlAPIRequest(createPeopleToUpdateOperation);
225+
226+
const graphqlOperation = updateManyOperationFactory({
227+
objectMetadataSingularName: 'person',
228+
objectMetadataPluralName: 'people',
229+
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
230+
filter: {
231+
id: {
232+
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
233+
},
234+
},
235+
data: {
236+
company: {
237+
connect: {
238+
where: { domainName: { primaryLinkUrl: 'company2.com' } },
239+
},
240+
},
241+
},
242+
});
243+
244+
const response = await makeGraphqlAPIRequest(graphqlOperation);
245+
246+
expect(response.body.data.updatePeople).toBeDefined();
247+
expect(response.body.data.updatePeople).toHaveLength(2);
248+
249+
expect(response.body.data.updatePeople[0].company.id).toBe(
250+
TEST_COMPANY_2_ID,
251+
);
252+
expect(response.body.data.updatePeople[1].company.id).toBe(
253+
TEST_COMPANY_2_ID,
254+
);
255+
});
111256
it('should throw an error if relation id field and relation connect field are both provided', async () => {
112257
const graphqlOperation = createOneOperationFactory({
113258
objectMetadataSingularName: 'person',

packages/twenty-server/test/integration/graphql/utils/create-many-operation-factory.util.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,25 @@ type CreateManyOperationFactoryParams = {
66
objectMetadataPluralName: string;
77
gqlFields: string;
88
data?: object;
9+
upsert?: boolean;
910
};
1011

1112
export const createManyOperationFactory = ({
1213
objectMetadataSingularName,
1314
objectMetadataPluralName,
1415
gqlFields,
1516
data = {},
17+
upsert = false,
1618
}: CreateManyOperationFactoryParams) => ({
1719
query: gql`
18-
mutation Create${capitalize(objectMetadataSingularName)}($data: [${capitalize(objectMetadataSingularName)}CreateInput!]!) {
19-
create${capitalize(objectMetadataPluralName)}(data: $data) {
20+
mutation Create${capitalize(objectMetadataSingularName)}($data: [${capitalize(objectMetadataSingularName)}CreateInput!]!, $upsert: Boolean) {
21+
create${capitalize(objectMetadataPluralName)}(data: $data, upsert: $upsert) {
2022
${gqlFields}
2123
}
2224
}
2325
`,
2426
variables: {
2527
data,
28+
upsert,
2629
},
2730
});

0 commit comments

Comments
 (0)