@axinom/mosaic-graphql-common 0.28.0-rc.14 → 0.28.0-rc.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,7 @@ import {
23
23
  } from 'graphile-build-pg';
24
24
  import { gql as gqlExtended } from 'graphile-utils';
25
25
  import GraphQL, {
26
+ GraphQLBoolean,
26
27
  GraphQLInputFieldMap,
27
28
  GraphQLInputObjectType,
28
29
  GraphQLList,
@@ -95,11 +96,10 @@ export const BulkEditAsyncPluginFactory = (
95
96
  );
96
97
  const hasForeignKeyRelations = foreignKeyRelations.length > 0;
97
98
 
98
- const addInputTypeForFKs = hasForeignKeyRelations
99
- ? generateInputTypeForFKs(
99
+ const clearInputTypeForFKs = hasForeignKeyRelations
100
+ ? generateClearInputTypeForFKs(
100
101
  build,
101
102
  pgTable,
102
- 'Add',
103
103
  excludedForeignKeyRelations,
104
104
  )
105
105
  : undefined;
@@ -113,6 +113,15 @@ export const BulkEditAsyncPluginFactory = (
113
113
  )
114
114
  : undefined;
115
115
 
116
+ const addInputTypeForFKs = hasForeignKeyRelations
117
+ ? generateInputTypeForFKs(
118
+ build,
119
+ pgTable,
120
+ 'Add',
121
+ excludedForeignKeyRelations,
122
+ )
123
+ : undefined;
124
+
116
125
  const BulkEditPayloadType = getBulkEditPayloadType(build);
117
126
 
118
127
  const args: Record<string, { type: GraphQL.GraphQLInputType }> = {
@@ -124,6 +133,12 @@ export const BulkEditAsyncPluginFactory = (
124
133
  },
125
134
  };
126
135
 
136
+ if (clearInputTypeForFKs) {
137
+ args.relatedEntitiesToClear = {
138
+ type: clearInputTypeForFKs,
139
+ };
140
+ }
141
+
127
142
  if (removeInputTypeForFKs) {
128
143
  args.relatedEntitiesToRemove = {
129
144
  type: removeInputTypeForFKs,
@@ -144,8 +159,9 @@ export const BulkEditAsyncPluginFactory = (
144
159
  input: {
145
160
  filter: { [key: string]: any };
146
161
  set?: { [key: string]: any };
147
- relatedEntitiesToRemove?: { [key: string]: [any] };
148
- relatedEntitiesToAdd?: { [key: string]: [any] };
162
+ relatedEntitiesToClear?: { [key: string]: boolean };
163
+ relatedEntitiesToRemove?: { [key: string]: any[] };
164
+ relatedEntitiesToAdd?: { [key: string]: any[] };
149
165
  },
150
166
  context: { [str: string]: unknown },
151
167
  resolveInfo: GraphQL.GraphQLResolveInfo,
@@ -159,6 +175,36 @@ export const BulkEditAsyncPluginFactory = (
159
175
  storeInboxMessage,
160
176
  } = context;
161
177
 
178
+ if (config === undefined) {
179
+ throw new Error(
180
+ `The service config [field: config] was unable to be resolved from the GQL context for the Bulk Edit operations.`,
181
+ );
182
+ }
183
+
184
+ if (jwtToken === undefined) {
185
+ throw new Error(
186
+ `The JWT token [field: jwtToken] was unable to be resolved from the GQL context for the Bulk Edit operations.`,
187
+ );
188
+ }
189
+
190
+ if (subject === undefined) {
191
+ throw new Error(
192
+ `The authenticated subject [field: subject] was unable to be resolved from the GQL context for the Bulk Edit operations.`,
193
+ );
194
+ }
195
+
196
+ if (envOwnerPool === undefined && ownerPool === undefined) {
197
+ throw new Error(
198
+ `The DB connection pool [fields: envOwnerPool, ownerPool] was unable to be resolved from the GQL context for the Bulk Edit operations.`,
199
+ );
200
+ }
201
+
202
+ if (storeInboxMessage === undefined) {
203
+ throw new Error(
204
+ `The storeInboxMessage function [field: storeInboxMessage] was unable to be resolved from the GQL context for the Bulk Edit operations. Ensure that the service uses the Transactional Inbox-Outbox implementation.`,
205
+ );
206
+ }
207
+
162
208
  const longLivedToken = await getLongLivedToken(
163
209
  jwtToken as string,
164
210
  config as BasicDBConfig,
@@ -178,12 +224,17 @@ export const BulkEditAsyncPluginFactory = (
178
224
  resolveInfo.schema,
179
225
  );
180
226
 
181
- const { set, relatedEntitiesToRemove, relatedEntitiesToAdd } =
182
- input;
227
+ const {
228
+ set,
229
+ relatedEntitiesToClear,
230
+ relatedEntitiesToRemove,
231
+ relatedEntitiesToAdd,
232
+ } = input;
183
233
 
184
- const { removals, additions } = getFkResolverPayload(
234
+ const { clears, removals, additions } = getFkResolverPayload(
185
235
  build,
186
236
  pgTable,
237
+ relatedEntitiesToClear,
187
238
  relatedEntitiesToRemove,
188
239
  relatedEntitiesToAdd,
189
240
  excludedForeignKeyRelations,
@@ -192,6 +243,7 @@ export const BulkEditAsyncPluginFactory = (
192
243
  await asyncResolverImplementation(
193
244
  {
194
245
  set: getMainResolverPayload(build, pgTable, set),
246
+ clears,
195
247
  removals,
196
248
  additions,
197
249
  },
@@ -276,12 +328,27 @@ interface FkResolverPayload {
276
328
  [fKTableName: string]: {
277
329
  parentKeyColumnName: string;
278
330
  fkGqlFieldNameToColumnNameMap: { [key: string]: string };
279
- inputData?: [{ [key: string]: any }];
331
+ inputData?: { [key: string]: any }[];
280
332
  };
281
333
  }
282
334
 
335
+ /**
336
+ * Metadata for clearing all entities in a foreign key relation
337
+ */
338
+ interface FkClearPayload {
339
+ [fKTableName: string]: {
340
+ parentKeyColumnName: string;
341
+ shouldClear: boolean;
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Complete payload for bulk item change operations
347
+ * Contains metadata for all possible actions: set, clear, remove, and add
348
+ */
283
349
  interface BulkItemChangePayload {
284
350
  set: MainResolverPayload;
351
+ clears: FkClearPayload;
285
352
  removals: FkResolverPayload;
286
353
  additions: FkResolverPayload;
287
354
  }
@@ -304,24 +371,40 @@ function getMainResolverPayload(
304
371
  };
305
372
  }
306
373
 
307
- // TODO: Can be potentially optimized (i.e. with getAffectedForeignKeyRelations or with asyncResolverImplementation)
374
+ /**
375
+ * Processes foreign key relation inputs and converts them into structured payloads
376
+ * for additions, removals, and clears operations.
377
+ *
378
+ * @returns Object containing metadata for all FK operations:
379
+ * - clears: Boolean flags for clearing all entities in a relation
380
+ * - removals: Specific entities to remove from relations
381
+ * - additions: Specific entities to add to relations
382
+ *
383
+ * TODO: Can be potentially optimized (i.e. with getAffectedForeignKeyRelations or with asyncResolverImplementation)
384
+ */
308
385
  function getFkResolverPayload(
309
386
  build: Build,
310
387
  pgTable: PgClass,
388
+ relatedEntitiesToClear:
389
+ | {
390
+ [key: string]: boolean;
391
+ }
392
+ | undefined,
311
393
  relatedEntitiesToRemove:
312
394
  | {
313
- [key: string]: [any];
395
+ [key: string]: any[];
314
396
  }
315
397
  | undefined,
316
398
  relatedEntitiesToAdd:
317
399
  | {
318
- [key: string]: [any];
400
+ [key: string]: any[];
319
401
  }
320
402
  | undefined,
321
403
  excludedForeignKeyRelations?: string[],
322
404
  ): {
323
- additions: FkResolverPayload;
405
+ clears: FkClearPayload;
324
406
  removals: FkResolverPayload;
407
+ additions: FkResolverPayload;
325
408
  } {
326
409
  const inflection: Inflection = build.inflection;
327
410
  const foreignKeyRelations = getAffectedForeignKeyRelations(
@@ -354,35 +437,51 @@ function getFkResolverPayload(
354
437
  };
355
438
  }
356
439
 
357
- const additions: FkResolverPayload = {};
440
+ const clears: FkClearPayload = {};
358
441
  const removals: FkResolverPayload = {};
442
+ const additions: FkResolverPayload = {};
359
443
 
360
444
  for (const fkProps of Object.keys(fkProcessingMetadata)) {
361
- if (relatedEntitiesToAdd) {
362
- additions[fkProps] = {
445
+ if (relatedEntitiesToClear) {
446
+ const shouldClear =
447
+ relatedEntitiesToClear[fkProcessingMetadata[fkProps].fkGqlFieldName];
448
+ if (shouldClear) {
449
+ clears[fkProps] = {
450
+ parentKeyColumnName:
451
+ fkProcessingMetadata[fkProps].parentKeyColumnName,
452
+ shouldClear: true,
453
+ };
454
+ }
455
+ }
456
+
457
+ if (relatedEntitiesToRemove) {
458
+ removals[fkProps] = {
363
459
  parentKeyColumnName: fkProcessingMetadata[fkProps].parentKeyColumnName,
364
460
  inputData:
365
- relatedEntitiesToAdd[fkProcessingMetadata[fkProps].fkGqlFieldName],
461
+ relatedEntitiesToRemove[fkProcessingMetadata[fkProps].fkGqlFieldName],
366
462
  fkGqlFieldNameToColumnNameMap:
367
463
  fkProcessingMetadata[fkProps].fkGqlFieldNameToColumnNameMap,
368
464
  };
369
465
  }
370
-
371
- if (relatedEntitiesToRemove) {
372
- removals[fkProps] = {
466
+ if (relatedEntitiesToAdd) {
467
+ additions[fkProps] = {
373
468
  parentKeyColumnName: fkProcessingMetadata[fkProps].parentKeyColumnName,
374
469
  inputData:
375
- relatedEntitiesToRemove[fkProcessingMetadata[fkProps].fkGqlFieldName],
470
+ relatedEntitiesToAdd[fkProcessingMetadata[fkProps].fkGqlFieldName],
376
471
  fkGqlFieldNameToColumnNameMap:
377
472
  fkProcessingMetadata[fkProps].fkGqlFieldNameToColumnNameMap,
378
473
  };
379
474
  }
380
475
  }
381
476
 
382
- return { additions, removals };
477
+ return { clears, removals, additions };
383
478
  }
384
479
 
385
- async function asyncResolverImplementation(
480
+ /**
481
+ * Exported for testing purposes
482
+ * @internal
483
+ */
484
+ export async function asyncResolverImplementation(
386
485
  bulkItemChangePayload: BulkItemChangePayload,
387
486
  entityIds: string[],
388
487
  config: MinimalConfig,
@@ -402,10 +501,14 @@ async function asyncResolverImplementation(
402
501
  IsolationLevel.ReadCommitted,
403
502
  pgSettings,
404
503
  async (txn) => {
405
- // Iterate all the entity IDs
504
+ // Process each entity ID and send messages in this order:
505
+ // 1. SET_FIELD_VALUES (update main entity fields)
506
+ // 2. CLEAR_RELATED_ENTITIES (clear all FK relations)
507
+ // 3. REMOVE_RELATED_ENTITY (remove specific FK entities, skip if cleared)
508
+ // 4. ADD_RELATED_ENTITY (add new FK entities)
406
509
  for (const entity_id of entityIds) {
510
+ // SET_FIELD_VALUES
407
511
  if (bulkItemChangePayload.set.inputData) {
408
- // Generate the SET_FIELD_VALUES payload
409
512
  const actionSetPayload: PerformItemChangeCommand = {
410
513
  table_name: bulkItemChangePayload.set.mainTableName,
411
514
  action: 'SET_FIELD_VALUES',
@@ -429,15 +532,52 @@ async function asyncResolverImplementation(
429
532
  );
430
533
  }
431
534
 
535
+ // CLEAR_RELATED_ENTITIES - Process clears FIRST before any deletions
536
+ const clearedRelations = new Set<string>();
537
+ if (bulkItemChangePayload.clears) {
538
+ const fkTableNames = Object.keys(bulkItemChangePayload.clears);
539
+
540
+ for (const fkTableName of fkTableNames) {
541
+ const fkClearMetadata = bulkItemChangePayload.clears[fkTableName];
542
+
543
+ if (fkClearMetadata.shouldClear) {
544
+ clearedRelations.add(fkTableName); // Track cleared relations
545
+
546
+ const clearPayload: PerformItemChangeCommand = {
547
+ table_name: fkTableName,
548
+ action: 'CLEAR_RELATED_ENTITIES',
549
+ stringified_condition: JSON.stringify({
550
+ [fkClearMetadata.parentKeyColumnName]: entity_id,
551
+ }),
552
+ stringified_payload: JSON.stringify({}),
553
+ };
554
+
555
+ // TODO: Add a DEBUG log for `clearPayload`
556
+
557
+ await sendMessage(
558
+ config,
559
+ storeInboxMessage,
560
+ clearPayload,
561
+ txn,
562
+ longLivedToken,
563
+ );
564
+ }
565
+ }
566
+ }
567
+
568
+ // REMOVE_RELATED_ENTITY - Skip if relation is being cleared
432
569
  if (bulkItemChangePayload.removals) {
433
570
  const fkTableNames = Object.keys(bulkItemChangePayload.removals);
434
571
 
435
- // Iterate each related FK table where entities will be removed
436
572
  for (const fkTableName of fkTableNames) {
573
+ // Skip if this relation is being cleared (optimization)
574
+ if (clearedRelations.has(fkTableName)) {
575
+ continue;
576
+ }
577
+
437
578
  const fkRemovalMetadata =
438
579
  bulkItemChangePayload.removals[fkTableName];
439
580
  if (fkRemovalMetadata.inputData) {
440
- // Generate the REMOVE_RELATED_ENTITY payload per entity of the FK table
441
581
  for (const itemToRemove of fkRemovalMetadata.inputData) {
442
582
  const removePayload: PerformItemChangeCommand = {
443
583
  table_name: fkTableName,
@@ -466,17 +606,17 @@ async function asyncResolverImplementation(
466
606
  }
467
607
  }
468
608
 
469
- if (Object.keys(bulkItemChangePayload.additions).length > 0) {
470
- const fkTables = Object.keys(bulkItemChangePayload.additions);
609
+ // ADD_RELATED_ENTITY
610
+ if (bulkItemChangePayload.additions) {
611
+ const fkTableNames = Object.keys(bulkItemChangePayload.additions);
471
612
 
472
- // Iterate each related FK table where entities will be added
473
- for (const fkTable of fkTables) {
474
- const fkAdditionMetadata = bulkItemChangePayload.additions[fkTable];
613
+ for (const fkTableName of fkTableNames) {
614
+ const fkAdditionMetadata =
615
+ bulkItemChangePayload.additions[fkTableName];
475
616
  if (fkAdditionMetadata.inputData) {
476
- // Generate the ADD_RELATED_ENTITY payload per entity of the FK table
477
617
  for (const itemToAdd of fkAdditionMetadata.inputData) {
478
618
  const addPayload: PerformItemChangeCommand = {
479
- table_name: fkTable,
619
+ table_name: fkTableName,
480
620
  action: 'ADD_RELATED_ENTITY',
481
621
  stringified_condition: '',
482
622
  stringified_payload: JSON.stringify({
@@ -549,6 +689,41 @@ function mapGqlInputObjectToSqlColumnNames(
549
689
  }, {});
550
690
  }
551
691
 
692
+ // Generate input type for clearing FK relations (boolean flags)
693
+ function generateClearInputTypeForFKs(
694
+ build: Build,
695
+ pgTable: PgClass,
696
+ excludedForeignKeyRelations?: string[],
697
+ ): GraphQLInputObjectType {
698
+ const inflection: Inflection = build.inflection;
699
+ const clearInputFieldsMap: GraphQL.Thunk<GraphQL.GraphQLInputFieldConfigMap> =
700
+ {};
701
+
702
+ // Filter only the interesting FK relations
703
+ const foreignKeyRelations = getAffectedForeignKeyRelations(
704
+ pgTable,
705
+ excludedForeignKeyRelations,
706
+ );
707
+
708
+ for (const fkConstraint of foreignKeyRelations) {
709
+ // Add a boolean field for each FK relation
710
+ clearInputFieldsMap[inflection.allRows(fkConstraint.class)] = {
711
+ type: GraphQLBoolean,
712
+ };
713
+ }
714
+
715
+ const ClearInputType = new GraphQLInputObjectType({
716
+ name: `BulkEditAsync${inflection.tableType(pgTable)}ClearInput`,
717
+ fields: () => ({
718
+ ...clearInputFieldsMap,
719
+ }),
720
+ });
721
+
722
+ build.addType(ClearInputType);
723
+
724
+ return ClearInputType;
725
+ }
726
+
552
727
  // TODO: Handle `insertable` | `deletable` GRANTS for FK Relations
553
728
  function generateInputTypeForFKs(
554
729
  build: Build,