@simtlix/simfinity-js 2.5.0 → 2.6.3

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +630 -1065
  3. package/.claude/worktrees/agitated-kepler/.claude/settings.local.json +0 -23
  4. package/.claude/worktrees/agitated-kepler/AGGREGATION_CHANGES_SUMMARY.md +0 -235
  5. package/.claude/worktrees/agitated-kepler/AGGREGATION_EXAMPLE.md +0 -567
  6. package/.claude/worktrees/agitated-kepler/LICENSE +0 -201
  7. package/.claude/worktrees/agitated-kepler/README.md +0 -3941
  8. package/.claude/worktrees/agitated-kepler/eslint.config.mjs +0 -71
  9. package/.claude/worktrees/agitated-kepler/package-lock.json +0 -4740
  10. package/.claude/worktrees/agitated-kepler/package.json +0 -41
  11. package/.claude/worktrees/agitated-kepler/src/auth/errors.js +0 -44
  12. package/.claude/worktrees/agitated-kepler/src/auth/expressions.js +0 -273
  13. package/.claude/worktrees/agitated-kepler/src/auth/index.js +0 -391
  14. package/.claude/worktrees/agitated-kepler/src/auth/rules.js +0 -274
  15. package/.claude/worktrees/agitated-kepler/src/const/QLOperator.js +0 -39
  16. package/.claude/worktrees/agitated-kepler/src/const/QLSort.js +0 -28
  17. package/.claude/worktrees/agitated-kepler/src/const/QLValue.js +0 -39
  18. package/.claude/worktrees/agitated-kepler/src/errors/internal-server.error.js +0 -11
  19. package/.claude/worktrees/agitated-kepler/src/errors/simfinity.error.js +0 -15
  20. package/.claude/worktrees/agitated-kepler/src/index.js +0 -2412
  21. package/.claude/worktrees/agitated-kepler/src/plugins.js +0 -53
  22. package/.claude/worktrees/agitated-kepler/src/scalars.js +0 -188
  23. package/.claude/worktrees/agitated-kepler/src/validators.js +0 -250
  24. package/.claude/worktrees/agitated-kepler/yarn.lock +0 -1154
  25. package/.cursor/rules/simfinity-architecture.mdc +0 -71
  26. package/.cursor/rules/simfinity-auth-module.mdc +0 -100
  27. package/.cursor/rules/simfinity-coding-standards.mdc +0 -62
  28. package/.cursor/rules/simfinity-core-functions.mdc +0 -84
  29. package/.cursor/rules/simfinity-documentation.mdc +0 -59
  30. package/.cursor/rules/simfinity-extensions.mdc +0 -121
  31. package/.cursor/rules/simfinity-testing.mdc +0 -90
  32. package/eslint.config.mjs +0 -71
  33. package/git-report.js +0 -224
package/src/index.js CHANGED
@@ -13,7 +13,6 @@ import QLSort from './const/QLSort.js';
13
13
 
14
14
  mongoose.set('strictQuery', false);
15
15
 
16
- // Custom JSON scalar type for aggregation results
17
16
  const GraphQLJSON = new GraphQLScalarType({
18
17
  name: 'JSON',
19
18
  description: 'The `JSON` scalar type represents JSON values as specified by ECMA-404',
@@ -29,6 +28,7 @@ const GraphQLJSON = new GraphQLScalarType({
29
28
  case Kind.BOOLEAN:
30
29
  return ast.value;
31
30
  case Kind.INT:
31
+ return parseInt(ast.value, 10);
32
32
  case Kind.FLOAT:
33
33
  return parseFloat(ast.value);
34
34
  case Kind.OBJECT: {
@@ -48,7 +48,6 @@ const GraphQLJSON = new GraphQLScalarType({
48
48
  },
49
49
  });
50
50
 
51
- // Adding 'extensions' field into instronspection query
52
51
  const RelationType = new GraphQLObjectType({
53
52
  name: 'RelationType',
54
53
  fields: () => ({
@@ -82,7 +81,6 @@ const fixedFieldsWithExtensions = () => {
82
81
  };
83
82
 
84
83
  __Field._fields = fixedFieldsWithExtensions;
85
- // End of adding 'extensions' field to instrospection query
86
84
 
87
85
  const typesDict = { types: {} };
88
86
  const waitingInputType = {};
@@ -133,9 +131,6 @@ export const preventCreatingCollection = (prevent) => {
133
131
  preventCollectionCreation = !!prevent;
134
132
  };
135
133
 
136
- /* Schema defines data on the Graph like object types(book type), relation between
137
- these object types and describes how it can reach into the graph to interact with
138
- the data to retrieve or mutate the data */
139
134
  const QLFilter = new GraphQLInputObjectType({
140
135
  name: 'QLFilter',
141
136
  fields: () => ({
@@ -263,17 +258,91 @@ const getEffectiveTypeName = (type) => {
263
258
 
264
259
  const isGraphQLisoDate = (typeName) => typeName === 'DateTime' || typeName === 'Date' || typeName === 'Time';
265
260
 
261
+ const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
262
+
263
+ const unwrapListAndNonNull = (type) => {
264
+ if (type instanceof GraphQLList || type instanceof GraphQLNonNull) {
265
+ return type.ofType;
266
+ }
267
+ return type;
268
+ };
269
+
270
+ const coerceDateValue = (fieldType, holder, key) => {
271
+ const typeName = getEffectiveTypeName(unwrapNonNull(fieldType));
272
+ if (!isGraphQLisoDate(typeName)) return;
273
+ const raw = holder[key];
274
+ if (Array.isArray(raw)) {
275
+ holder[key] = raw.map((v) => v && new Date(v));
276
+ } else {
277
+ holder[key] = raw && new Date(raw);
278
+ }
279
+ };
280
+
281
+ const isCustomValidatedScalar = (type) => type instanceof GraphQLScalarType && type.baseScalarType;
282
+
283
+ const matchesScalar = (fieldType, target) => {
284
+ if (fieldType === target) return true;
285
+ if (isNonNullOfTypeForNotScalar(fieldType, target)) return true;
286
+ if (isCustomValidatedScalar(fieldType) && fieldType.baseScalarType === target) return true;
287
+ if (isNonNullOfType(fieldType, GraphQLScalarType)
288
+ && isCustomValidatedScalar(fieldType.ofType)
289
+ && fieldType.ofType.baseScalarType === target) return true;
290
+ return false;
291
+ };
292
+
293
+ const buildRelationLookup = ({
294
+ collectionName, localField, foreignField, alias,
295
+ }) => ({
296
+ lookup: {
297
+ $lookup: {
298
+ from: collectionName, foreignField, localField, as: alias,
299
+ },
300
+ },
301
+ unwind: { $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
302
+ });
303
+
304
+ const escapeRegex = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
305
+
306
+ const PATH_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
307
+
308
+ const assertValidFilterPath = (path) => {
309
+ if (typeof path !== 'string' || path.length === 0) {
310
+ throw new SimfinityError('Filter path must be a non-empty string', 'INVALID_FILTER_PATH', 400);
311
+ }
312
+ for (const segment of path.split('.')) {
313
+ if (!PATH_SEGMENT_RE.test(segment)) {
314
+ throw new SimfinityError(`Invalid filter path segment: "${segment}"`, 'INVALID_FILTER_PATH', 400);
315
+ }
316
+ }
317
+ };
318
+
319
+ const OP_TO_MONGO = {
320
+ LT: (v) => ({ $lt: v }),
321
+ GT: (v) => ({ $gt: v }),
322
+ LTE: (v) => ({ $lte: v }),
323
+ GTE: (v) => ({ $gte: v }),
324
+ NE: (v) => ({ $ne: v }),
325
+ BTW: (v) => ({ $gte: v[0], $lte: v[1] }),
326
+ LIKE: (v) => ({ $regex: `.*${escapeRegex(v)}.*` }),
327
+ };
328
+
329
+ const AGG_OP_TO_MONGO = {
330
+ SUM: (path) => ({ $sum: `$${path}` }),
331
+ COUNT: () => ({ $sum: 1 }),
332
+ AVG: (path) => ({ $avg: `$${path}` }),
333
+ MIN: (path) => ({ $min: `$${path}` }),
334
+ MAX: (path) => ({ $max: `$${path}` }),
335
+ };
336
+
266
337
  function createValidatedScalar(name, description, baseScalarType, validate) {
267
338
  if (!baseScalarType) {
268
339
  throw new Error('baseScalarType is required');
269
340
  }
270
341
 
271
- // Validate that baseScalarType is a valid GraphQL scalar type
272
342
  if (!(baseScalarType instanceof GraphQLScalarType)) {
273
343
  throw new Error('baseScalarType must be a valid GraphQL scalar type');
274
344
  }
275
345
 
276
- // Check if it's one of the standard GraphQL scalar types
277
346
  const validScalarTypes = [GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLID];
278
347
  const isValidStandardType = validScalarTypes.some((type) => baseScalarType === type);
279
348
 
@@ -286,10 +355,9 @@ function createValidatedScalar(name, description, baseScalarType, validate) {
286
355
  Int: Kind.INT,
287
356
  Float: Kind.FLOAT,
288
357
  Boolean: Kind.BOOLEAN,
289
- ID: Kind.STRING, // IDs are represented as strings in AST
358
+ ID: Kind.STRING,
290
359
  };
291
360
 
292
- // Try to infer the kind from the baseScalarType name
293
361
  const baseKind = kindMap[baseScalarType.name] || Kind.STRING;
294
362
 
295
363
  const scalar = new GraphQLScalarType({
@@ -327,7 +395,13 @@ function createValidatedScalar(name, description, baseScalarType, validate) {
327
395
  const createTypeWithExcludedField = (inputNamePrefix, originalType, fieldToExclude) => {
328
396
  const originalFields = originalType.getFields();
329
397
  const newFields = Object.fromEntries(
330
- Object.entries(originalFields).filter(([fieldName]) => fieldName !== fieldToExclude),
398
+ Object.entries(originalFields)
399
+ .filter(([fieldName]) => fieldName !== fieldToExclude)
400
+ .map(([fieldName, field]) => [fieldName, {
401
+ type: field.type,
402
+ description: field.description,
403
+ defaultValue: field.defaultValue,
404
+ }]),
331
405
  );
332
406
 
333
407
  return new GraphQLInputObjectType({
@@ -340,11 +414,8 @@ const createOneToManyInputType = (inputNamePrefix, fieldEntryName,
340
414
  inputType, updateInputType, connectionField) => {
341
415
  let inputTypeForAdd = inputType;
342
416
 
343
- // If a gqltype is provided, create a new input type for 'added'
344
- // that excludes the field named after the gqltype.
345
417
  if (connectionField) {
346
- const fieldToExclude = connectionField;
347
- inputTypeForAdd = createTypeWithExcludedField(inputNamePrefix, inputType, fieldToExclude);
418
+ inputTypeForAdd = createTypeWithExcludedField(inputNamePrefix, inputType, connectionField);
348
419
  }
349
420
 
350
421
  return new GraphQLInputObjectType({
@@ -397,9 +468,9 @@ const buildInputType = (gqltype) => {
397
468
 
398
469
  if (!fieldEntry.extensions || !fieldEntry.extensions.readOnly) {
399
470
  const hasStateMachine = !!typesDict.types[gqltype.name].stateMachine;
400
- const doesEstateFieldExistButIsManagedByStateMachine = !!(fieldEntryName === 'state' && hasStateMachine);
471
+ const stateFieldManagedByStateMachine = !!(fieldEntryName === 'state' && hasStateMachine);
401
472
 
402
- if (!doesEstateFieldExistButIsManagedByStateMachine) {
473
+ if (!stateFieldManagedByStateMachine) {
403
474
  if (fieldEntry.type instanceof GraphQLScalarType
404
475
  || fieldEntry.type instanceof GraphQLEnumType
405
476
  || isNonNullOfType(fieldEntry.type, GraphQLScalarType)
@@ -435,8 +506,8 @@ const buildInputType = (gqltype) => {
435
506
  if (fieldEntry.type.ofType === gqltype) {
436
507
  selfReferenceCollections[fieldEntryName] = fieldEntry;
437
508
  } else {
438
- const listInputTypeForAdd = graphQLListInputType(typesDict, fieldEntry, fieldEntryName, gqltype.name + 'A', fieldEntry.extensions?.relation?.connectionField);
439
- const listInputTypeForUpdate = graphQLListInputType(typesDictForUpdate, fieldEntry, fieldEntryName, gqltype.name +'U', fieldEntry.extensions?.relation?.connectionField);
509
+ const listInputTypeForAdd = graphQLListInputType(typesDict, fieldEntry, fieldEntryName, `${gqltype.name}A`, fieldEntry.extensions?.relation?.connectionField);
510
+ const listInputTypeForUpdate = graphQLListInputType(typesDictForUpdate, fieldEntry, fieldEntryName, `${gqltype.name}U`, fieldEntry.extensions?.relation?.connectionField);
440
511
  if (listInputTypeForAdd && listInputTypeForUpdate) {
441
512
  fieldArg.type = listInputTypeForAdd;
442
513
  fieldArgForUpdate.type = listInputTypeForUpdate;
@@ -508,29 +579,35 @@ const getInputType = (type) => typesDict.types[type.name].inputType;
508
579
  export { getInputType };
509
580
 
510
581
  const buildPendingInputTypes = (waitingForInputType) => {
511
- const stillWaitingInputType = {};
512
- let isThereAtLeastOneWaiting = false;
513
-
514
- Object.entries(waitingForInputType).forEach(([key, value]) => {
515
- const { gqltype } = value;
582
+ let pending = waitingForInputType;
583
+ let previousPendingCount = Object.keys(pending).length + 1;
584
+
585
+ while (Object.keys(pending).length > 0) {
586
+ const currentCount = Object.keys(pending).length;
587
+ if (currentCount >= previousPendingCount) {
588
+ const unresolved = Object.keys(pending).join(', ');
589
+ throw new SimfinityError(
590
+ `Could not build input types for: ${unresolved}. Check for circular or misconfigured relations.`,
591
+ 'INPUT_TYPE_UNRESOLVED',
592
+ 500,
593
+ );
594
+ }
595
+ previousPendingCount = currentCount;
516
596
 
517
- if (!typesDict.types[gqltype.name].inputType) {
518
- const buildInputTypeResult = buildInputType(gqltype);
597
+ const stillWaiting = {};
598
+ for (const [key, value] of Object.entries(pending)) {
599
+ const { gqltype } = value;
600
+ if (typesDict.types[gqltype.name].inputType) continue;
519
601
 
520
- if (buildInputTypeResult && buildInputTypeResult.inputTypeBody
521
- && buildInputTypeResult.inputTypeBodyForUpdate) {
522
- typesDict.types[gqltype.name].inputType = buildInputTypeResult.inputTypeBody;
523
- typesDictForUpdate.types[gqltype.name].inputType = buildInputTypeResult
524
- .inputTypeBodyForUpdate;
602
+ const result = buildInputType(gqltype);
603
+ if (result && result.inputTypeBody && result.inputTypeBodyForUpdate) {
604
+ typesDict.types[gqltype.name].inputType = result.inputTypeBody;
605
+ typesDictForUpdate.types[gqltype.name].inputType = result.inputTypeBodyForUpdate;
525
606
  } else {
526
- stillWaitingInputType[key] = value;
527
- isThereAtLeastOneWaiting = true;
607
+ stillWaiting[key] = value;
528
608
  }
529
609
  }
530
- });
531
-
532
- if (isThereAtLeastOneWaiting) {
533
- buildPendingInputTypes(stillWaitingInputType);
610
+ pending = stillWaiting;
534
611
  }
535
612
  };
536
613
 
@@ -573,9 +650,11 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
573
650
  null, operation, session)).modelArgs;
574
651
  }
575
652
  } else {
576
- modelArgs[fieldEntry.name] = new mongoose.Types
577
- .ObjectId(args[fieldEntryName].id);
578
- console.warn(`Configuration issue: Field ${fieldEntryName} does not define extensions.relation`);
653
+ throw new SimfinityError(
654
+ `Field ${gqltype.name}.${fieldEntryName} is an object type but does not define extensions.relation`,
655
+ 'MISSING_RELATION_EXTENSION',
656
+ 500,
657
+ );
579
658
  }
580
659
  } else if (fieldEntry.type instanceof GraphQLList) {
581
660
  const { ofType } = fieldEntry.type;
@@ -616,56 +695,58 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
616
695
  return { modelArgs, collectionFields };
617
696
  };
618
697
 
619
- const executeRegisteredMutation = async (args, callback, session) => {
698
+ const MAX_TRANSIENT_RETRIES = 5;
699
+
700
+ const withTransaction = async (session, body) => {
701
+ const ownsSession = !session;
620
702
  const mySession = session || await mongoose.startSession();
621
- await mySession.startTransaction();
622
703
  try {
623
- const newObject = await callback(args, mySession);
624
- await mySession.commitTransaction();
625
- mySession.endSession();
626
- return newObject;
627
- } catch (error) {
628
- await mySession.abortTransaction();
629
- if (error.errorLabels && error.errorLabels.includes('TransientTransactionError')) {
630
- return executeRegisteredMutation(args, callback, mySession);
704
+ for (let attempt = 0; attempt <= MAX_TRANSIENT_RETRIES; attempt++) {
705
+ await mySession.startTransaction();
706
+ try {
707
+ const result = await body(mySession);
708
+ await mySession.commitTransaction();
709
+ return result;
710
+ } catch (error) {
711
+ await mySession.abortTransaction();
712
+ const isTransient = error.errorLabels?.includes('TransientTransactionError');
713
+ if (isTransient && attempt < MAX_TRANSIENT_RETRIES) {
714
+ continue;
715
+ }
716
+ throw error;
717
+ }
718
+ }
719
+ throw new SimfinityError('Transaction exceeded retry limit', 'TRANSACTION_RETRY_EXCEEDED', 500);
720
+ } finally {
721
+ if (ownsSession) {
722
+ mySession.endSession();
631
723
  }
632
- mySession.endSession();
633
- throw error;
634
724
  }
635
725
  };
636
726
 
637
- const iterateonCollectionFields = async (materializedModel, gqltype, objectId, session, context) => {
638
- for (const [collectionFieldKey, collectionField] of
639
- Object.entries(materializedModel.collectionFields)) {
727
+ const executeRegisteredMutation = (args, callback, context, session) => withTransaction(
728
+ session,
729
+ (mySession) => callback(args, mySession, context),
730
+ );
731
+
732
+ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, session, context) => {
733
+ for (const [collectionFieldKey, collectionField] of Object.entries(materializedModel.collectionFields)) {
640
734
  if (collectionField.added) {
641
-
642
735
  await executeItemFunction(gqltype, collectionFieldKey, objectId, session,
643
736
  collectionField.added, operations.SAVE, context);
644
737
  }
645
738
  if (collectionField.updated) {
646
-
647
739
  await executeItemFunction(gqltype, collectionFieldKey, objectId, session,
648
740
  collectionField.updated, operations.UPDATE, context);
649
741
  }
650
742
  if (collectionField.deleted) {
651
-
652
743
  await executeItemFunction(gqltype, collectionFieldKey, objectId, session,
653
744
  collectionField.deleted, operations.DELETE, context);
654
745
  }
655
746
  }
656
747
  };
657
748
 
658
- const onDeleteObject = async (Model, gqltype, controller, args, session, context) => {
659
- const deletedObject = await Model.findById({ _id: args }).session(session).lean();
660
-
661
- if (controller && controller.onDelete) {
662
- await controller.onDelete(deletedObject, session, context);
663
- }
664
-
665
- return Model.findByIdAndDelete({ _id: args }).session(session);
666
- };
667
-
668
- const onDeleteSubject = async (Model, controller, id, session, context) => {
749
+ const onDelete = async (Model, controller, id, session, context) => {
669
750
  const currentObject = await Model.findById({ _id: id }).session(session).lean();
670
751
 
671
752
  if (controller && controller.onDelete) {
@@ -675,36 +756,49 @@ const onDeleteSubject = async (Model, controller, id, session, context) => {
675
756
  return Model.findByIdAndDelete({ _id: id }).session(session);
676
757
  };
677
758
 
759
+ const getEmbeddedFieldNames = (gqltype) => {
760
+ const cached = typesDict.types[gqltype.name];
761
+ if (cached && cached.embeddedFieldNames) return cached.embeddedFieldNames;
762
+ const names = [];
763
+ for (const [fieldName, fieldEntry] of Object.entries(gqltype.getFields())) {
764
+ if (fieldEntry.extensions?.relation?.embedded) names.push(fieldName);
765
+ }
766
+ if (cached) cached.embeddedFieldNames = names;
767
+ return names;
768
+ };
769
+
678
770
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
679
771
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
680
772
  const objectId = args.id;
681
-
682
- const currentObject = await Model.findById({ _id: objectId }).lean();
683
-
684
773
  const argTypes = gqltype.getFields();
685
-
686
- Object.entries(argTypes).forEach(([fieldEntryName, fieldEntry]) => {
687
- if (fieldEntry.extensions && fieldEntry.extensions.relation
688
- && fieldEntry.extensions.relation.embedded) {
689
- const oldObjectData = currentObject[fieldEntryName];
690
- const newObjectData = materializedModel.modelArgs[fieldEntryName];
691
- if (newObjectData) {
692
- if (Array.isArray(oldObjectData) && Array.isArray(newObjectData)) {
693
- materializedModel.modelArgs[fieldEntryName] = newObjectData;
694
- } else {
695
- materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
774
+ const embeddedFieldNames = getEmbeddedFieldNames(gqltype);
775
+
776
+ if (embeddedFieldNames.length > 0) {
777
+ const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
778
+ const currentObject = await Model.findById(objectId, projection).session(session).lean();
779
+ if (currentObject) {
780
+ for (const fieldEntryName of embeddedFieldNames) {
781
+ const oldObjectData = currentObject[fieldEntryName];
782
+ const newObjectData = materializedModel.modelArgs[fieldEntryName];
783
+ if (newObjectData) {
784
+ if (Array.isArray(oldObjectData) && Array.isArray(newObjectData)) {
785
+ materializedModel.modelArgs[fieldEntryName] = newObjectData;
786
+ } else {
787
+ materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
788
+ }
696
789
  }
697
790
  }
698
791
  }
792
+ }
699
793
 
700
- if (args[fieldEntryName] === null
701
- && !(fieldEntry.type instanceof GraphQLNonNull)) {
794
+ for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
795
+ if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull)) {
702
796
  materializedModel.modelArgs = {
703
797
  ...materializedModel.modelArgs,
704
798
  $unset: { ...materializedModel.modelArgs.$unset, [fieldEntryName]: '' },
705
799
  };
706
800
  }
707
- });
801
+ }
708
802
 
709
803
  if (controller && controller.onUpdating) {
710
804
  await controller.onUpdating(objectId, materializedModel.modelArgs, session, context);
@@ -715,7 +809,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
715
809
  ).session(session);
716
810
 
717
811
  if (materializedModel.collectionFields) {
718
- await iterateonCollectionFields(materializedModel, gqltype, objectId, session, context);
812
+ await iterateOnCollectionFields(materializedModel, gqltype, objectId, session, context);
719
813
  }
720
814
 
721
815
  if (controller && controller.onUpdated) {
@@ -726,7 +820,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
726
820
  };
727
821
 
728
822
  const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
729
- const storedModel = await Model.findById(args.id);
823
+ const storedModel = await Model.findById(args.id).session(session);
730
824
  if (!storedModel) {
731
825
  throw new SimfinityError(`${gqltype.name} ${args.id} is not valid`, 'NOT_VALID_ID', 404);
732
826
  }
@@ -762,10 +856,9 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
762
856
  result = result.toObject();
763
857
 
764
858
  if (materializedModel.collectionFields) {
765
- await iterateonCollectionFields(materializedModel, gqltype, newObject._id, session, context);
859
+ await iterateOnCollectionFields(materializedModel, gqltype, newObject._id, session, context);
766
860
  }
767
861
 
768
-
769
862
  if (controller && controller.onSaved) {
770
863
  await controller.onSaved(result, args, session, context);
771
864
  }
@@ -780,38 +873,23 @@ export const saveObject = async (typeName, args, session, context) => {
780
873
  return onSaveObject(type.model, type.gqltype, type.controller, args, session, null, context);
781
874
  };
782
875
 
783
- const executeOperation = async (Model, gqltype, controller,
784
- args, operation, actionField, session, context) => {
785
- const mySession = session || await mongoose.startSession();
786
- await mySession.startTransaction();
787
- try {
788
- let newObject = null;
876
+ const executeOperation = (Model, gqltype, controller, args, operation, actionField, session, context) => withTransaction(
877
+ session,
878
+ async (mySession) => {
789
879
  switch (operation) {
790
880
  case operations.SAVE:
791
- newObject = await onSaveObject(Model, gqltype, controller, args, mySession, null, context);
792
- break;
881
+ return onSaveObject(Model, gqltype, controller, args, mySession, null, context);
793
882
  case operations.UPDATE:
794
- newObject = await onUpdateSubject(Model, gqltype, controller, args, mySession, null, context);
795
- break;
883
+ return onUpdateSubject(Model, gqltype, controller, args, mySession, null, context);
796
884
  case operations.DELETE:
797
- newObject = await onDeleteObject(Model, gqltype, controller, args, mySession, context);
798
- break;
885
+ return onDelete(Model, controller, args, mySession, context);
799
886
  case operations.STATE_CHANGED:
800
- newObject = await onStateChanged(Model, gqltype, controller, args, mySession, actionField, context);
801
- break;
802
- }
803
- await mySession.commitTransaction();
804
- mySession.endSession();
805
- return newObject;
806
- } catch (error) {
807
- await mySession.abortTransaction();
808
- if (error.errorLabels && error.errorLabels.includes('TransientTransactionError')) {
809
- return executeOperation(Model, gqltype, controller, args, operation, actionField, mySession, context);
887
+ return onStateChanged(Model, gqltype, controller, args, mySession, actionField, context);
888
+ default:
889
+ return null;
810
890
  }
811
- mySession.endSession();
812
- throw error;
813
- }
814
- };
891
+ },
892
+ );
815
893
 
816
894
  const executeItemFunction = async (gqltype, collectionField, objectId, session,
817
895
  collectionFieldsList, operationType, context) => {
@@ -840,7 +918,7 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
840
918
  break;
841
919
  case operations.DELETE:
842
920
  operationFunction = async (collectionItem) => {
843
- await onDeleteSubject(typesDict.types[collectionGQLType.name].model,
921
+ await onDelete(typesDict.types[collectionGQLType.name].model,
844
922
  typesDict.types[collectionGQLType.name].controller, collectionItem, session, context);
845
923
  };
846
924
  }
@@ -853,27 +931,25 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
853
931
  const shouldNotBeIncludedInSchema = (includedTypes,
854
932
  type) => includedTypes && !includedTypes.includes(type);
855
933
 
856
- const excecuteMiddleware = (context) => {
934
+ const executeMiddleware = async (context) => {
857
935
  const buildNext = (middlewaresParam) => {
858
936
  if (!middlewaresParam) {
859
- return () => {};
937
+ return async () => {};
860
938
  }
861
- const next = () => {
939
+ return async () => {
862
940
  const middleware = middlewaresParam[0];
863
941
  if (middleware) {
864
- middleware(context, buildNext(middlewaresParam.slice(1)));
942
+ await middleware(context, buildNext(middlewaresParam.slice(1)));
865
943
  }
866
944
  };
867
- return next;
868
945
  };
869
946
 
870
- const middleware = buildNext(middlewares);
871
- middleware();
947
+ await buildNext(middlewares)();
872
948
  };
873
949
 
874
950
  const executeScope = async (params) => {
875
951
  const { type, args, operation, context } = params;
876
-
952
+
877
953
  if (!type || !type.gqltype || !type.gqltype.extensions) {
878
954
  return null;
879
955
  }
@@ -888,12 +964,7 @@ const executeScope = async (params) => {
888
964
  return null;
889
965
  }
890
966
 
891
- // Call the scope function with the same params as middleware
892
- const result = await scopeFunction({ type, args, operation, context });
893
-
894
- // For get_by_id, the scope function returns additional filters to merge
895
- // For find and aggregate, it modifies args in place
896
- return result;
967
+ return scopeFunction({ type, args, operation, context });
897
968
  };
898
969
 
899
970
  const buildMutation = (name, includedMutationTypes, includedCustomMutations) => {
@@ -920,7 +991,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
920
991
  context,
921
992
  };
922
993
 
923
- excecuteMiddleware(params);
994
+ await executeMiddleware(params);
924
995
  return executeOperation(type.model, type.gqltype, type.controller,
925
996
  args.input, operations.SAVE, null, null, context);
926
997
  },
@@ -937,7 +1008,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
937
1008
  context,
938
1009
  };
939
1010
 
940
- excecuteMiddleware(params);
1011
+ await executeMiddleware(params);
941
1012
  return executeOperation(type.model, type.gqltype, type.controller,
942
1013
  args.id, operations.DELETE, null, null, context);
943
1014
  },
@@ -962,7 +1033,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
962
1033
  context,
963
1034
  };
964
1035
 
965
- excecuteMiddleware(params);
1036
+ await executeMiddleware(params);
966
1037
  return executeOperation(type.model, type.gqltype, type.controller,
967
1038
  args.input, operations.UPDATE, null, null, context);
968
1039
  },
@@ -984,7 +1055,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
984
1055
  context,
985
1056
  };
986
1057
 
987
- excecuteMiddleware(params);
1058
+ await executeMiddleware(params);
988
1059
  return executeOperation(type.model, type.gqltype, type.controller,
989
1060
  args.input, operations.STATE_CHANGED, actionField, null, context);
990
1061
  },
@@ -1011,8 +1082,8 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1011
1082
  entry,
1012
1083
  context,
1013
1084
  };
1014
- excecuteMiddleware(params);
1015
- return executeRegisteredMutation(args.input, registeredMutation.callback);
1085
+ await executeMiddleware(params);
1086
+ return executeRegisteredMutation(args.input, registeredMutation.callback, context);
1016
1087
  },
1017
1088
  };
1018
1089
  }
@@ -1021,102 +1092,64 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1021
1092
  return new GraphQLObjectType(rootQueryArgs);
1022
1093
  };
1023
1094
 
1095
+ const listItemMatchesScalar = (listType, target) => {
1096
+ const ofType = listType.ofType;
1097
+ return ofType === target
1098
+ || (isCustomValidatedScalar(ofType) && ofType.baseScalarType === target);
1099
+ };
1100
+
1101
+ const withUnique = (fieldEntry, mongoType) => (fieldEntry.extensions && fieldEntry.extensions.unique
1102
+ ? { type: mongoType, unique: true }
1103
+ : mongoType);
1104
+
1024
1105
  const generateSchemaDefinition = (gqlType) => {
1025
1106
  const argTypes = gqlType.getFields();
1026
-
1027
1107
  const schemaArg = {};
1028
1108
 
1029
1109
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1030
- // Helper function to get the base scalar type for custom validated scalars
1031
- const getBaseScalarType = (scalarType) => scalarType.baseScalarType || scalarType;
1032
-
1033
- // Helper function to check if a type is a custom validated scalar
1034
- const isCustomValidatedScalar = (type) => type instanceof GraphQLScalarType && type.baseScalarType;
1110
+ const { type } = fieldEntry;
1035
1111
 
1036
- if (fieldEntry.type === GraphQLID || isNonNullOfTypeForNotScalar(fieldEntry.type, GraphQLID)) {
1112
+ if (matchesScalar(type, GraphQLID)) {
1037
1113
  schemaArg[fieldEntryName] = mongoose.Schema.Types.ObjectId;
1038
- } else if (fieldEntry.type === GraphQLString
1039
- || isNonNullOfTypeForNotScalar(fieldEntry.type, GraphQLString)
1040
- || (isCustomValidatedScalar(fieldEntry.type) && getBaseScalarType(fieldEntry.type) === GraphQLString)
1041
- || (isNonNullOfType(fieldEntry.type, GraphQLScalarType) && isCustomValidatedScalar(fieldEntry.type.ofType) && getBaseScalarType(fieldEntry.type.ofType) === GraphQLString)) {
1042
- if (fieldEntry.extensions && fieldEntry.extensions.unique) {
1043
- schemaArg[fieldEntryName] = { type: String, unique: true };
1044
- } else {
1045
- schemaArg[fieldEntryName] = String;
1046
- }
1047
- } else if (fieldEntry.type instanceof GraphQLEnumType
1048
- || isNonNullOfType(fieldEntry.type, GraphQLEnumType)) {
1049
- if (fieldEntry.extensions && fieldEntry.extensions.unique) {
1050
- schemaArg[fieldEntryName] = { type: String, unique: true };
1051
- } else {
1052
- schemaArg[fieldEntryName] = String;
1053
- }
1054
- } else if (fieldEntry.type === GraphQLInt
1055
- || isNonNullOfTypeForNotScalar(fieldEntry.type, GraphQLInt)
1056
- || (isCustomValidatedScalar(fieldEntry.type) && getBaseScalarType(fieldEntry.type) === GraphQLInt)
1057
- || (isNonNullOfType(fieldEntry.type, GraphQLScalarType) && isCustomValidatedScalar(fieldEntry.type.ofType) && getBaseScalarType(fieldEntry.type.ofType) === GraphQLInt)) {
1058
- if (fieldEntry.extensions && fieldEntry.extensions.unique) {
1059
- schemaArg[fieldEntryName] = { type: Number, unique: true };
1060
- } else {
1061
- schemaArg[fieldEntryName] = Number;
1062
- }
1063
- } else if (fieldEntry.type === GraphQLFloat
1064
- || isNonNullOfTypeForNotScalar(fieldEntry.type, GraphQLFloat)
1065
- || (isCustomValidatedScalar(fieldEntry.type) && getBaseScalarType(fieldEntry.type) === GraphQLFloat)
1066
- || (isNonNullOfType(fieldEntry.type, GraphQLScalarType) && isCustomValidatedScalar(fieldEntry.type.ofType) && getBaseScalarType(fieldEntry.type.ofType) === GraphQLFloat)) {
1067
- if (fieldEntry.extensions && fieldEntry.extensions.unique) {
1068
- schemaArg[fieldEntryName] = { type: Number, unique: true };
1069
- } else {
1070
- schemaArg[fieldEntryName] = Number;
1071
- }
1072
- } else if (fieldEntry.type === GraphQLBoolean
1073
- || isNonNullOfTypeForNotScalar(fieldEntry.type, GraphQLBoolean)
1074
- || (isCustomValidatedScalar(fieldEntry.type) && getBaseScalarType(fieldEntry.type) === GraphQLBoolean)
1075
- || (isNonNullOfType(fieldEntry.type, GraphQLScalarType) && isCustomValidatedScalar(fieldEntry.type.ofType) && getBaseScalarType(fieldEntry.type.ofType) === GraphQLBoolean)) {
1114
+ } else if (matchesScalar(type, GraphQLString)
1115
+ || type instanceof GraphQLEnumType
1116
+ || isNonNullOfType(type, GraphQLEnumType)) {
1117
+ schemaArg[fieldEntryName] = withUnique(fieldEntry, String);
1118
+ } else if (matchesScalar(type, GraphQLInt) || matchesScalar(type, GraphQLFloat)) {
1119
+ schemaArg[fieldEntryName] = withUnique(fieldEntry, Number);
1120
+ } else if (matchesScalar(type, GraphQLBoolean)) {
1076
1121
  schemaArg[fieldEntryName] = Boolean;
1077
- } else if (fieldEntry.type instanceof GraphQLObjectType
1078
- || isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
1122
+ } else if (type instanceof GraphQLObjectType || isNonNullOfType(type, GraphQLObjectType)) {
1079
1123
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
1080
1124
  if (!fieldEntry.extensions.relation.embedded) {
1081
- schemaArg[fieldEntry.extensions.relation.connectionField ? fieldEntry.extensions.relation.connectionField : fieldEntry.name] = mongoose
1082
- .Schema.Types.ObjectId;
1125
+ const key = fieldEntry.extensions.relation.connectionField || fieldEntry.name;
1126
+ schemaArg[key] = mongoose.Schema.Types.ObjectId;
1083
1127
  } else {
1084
- let entryType = fieldEntry.type;
1085
- if (entryType instanceof GraphQLNonNull) {
1086
- entryType = entryType.ofType;
1087
- }
1088
- if (entryType !== gqlType) {
1089
- schemaArg[fieldEntryName] = generateSchemaDefinition(entryType);
1090
- } else {
1128
+ const entryType = unwrapNonNull(type);
1129
+ if (entryType === gqlType) {
1091
1130
  throw new Error('A type cannot have a field of its same type and embedded');
1092
1131
  }
1132
+ schemaArg[fieldEntryName] = generateSchemaDefinition(entryType);
1093
1133
  }
1094
1134
  }
1095
- } else if (fieldEntry.type instanceof GraphQLList) {
1135
+ } else if (type instanceof GraphQLList) {
1096
1136
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
1097
1137
  if (fieldEntry.extensions.relation.embedded) {
1098
- const entryType = fieldEntry.type.ofType;
1099
- if (entryType !== gqlType) {
1100
- schemaArg[fieldEntryName] = [generateSchemaDefinition(entryType)];
1101
- } else {
1138
+ if (type.ofType === gqlType) {
1102
1139
  throw new Error('A type cannot have a field of its same type and embedded');
1103
1140
  }
1141
+ schemaArg[fieldEntryName] = [generateSchemaDefinition(type.ofType)];
1104
1142
  }
1105
- } else if (fieldEntry.type.ofType === GraphQLString
1106
- || fieldEntry.type.ofType instanceof GraphQLEnumType
1107
- || (isCustomValidatedScalar(fieldEntry.type.ofType) && getBaseScalarType(fieldEntry.type.ofType) === GraphQLString)) {
1143
+ } else if (listItemMatchesScalar(type, GraphQLString) || type.ofType instanceof GraphQLEnumType) {
1108
1144
  schemaArg[fieldEntryName] = [String];
1109
- } else if (fieldEntry.type.ofType === GraphQLBoolean
1110
- || (isCustomValidatedScalar(fieldEntry.type.ofType) && getBaseScalarType(fieldEntry.type.ofType) === GraphQLBoolean)) {
1145
+ } else if (listItemMatchesScalar(type, GraphQLBoolean)) {
1111
1146
  schemaArg[fieldEntryName] = [Boolean];
1112
- } else if (fieldEntry.type.ofType === GraphQLInt || fieldEntry.type.ofType === GraphQLFloat
1113
- || (isCustomValidatedScalar(fieldEntry.type.ofType) && (getBaseScalarType(fieldEntry.type.ofType) === GraphQLInt || getBaseScalarType(fieldEntry.type.ofType) === GraphQLFloat))) {
1147
+ } else if (listItemMatchesScalar(type, GraphQLInt) || listItemMatchesScalar(type, GraphQLFloat)) {
1114
1148
  schemaArg[fieldEntryName] = [Number];
1115
- } else if (isGraphQLisoDate(getEffectiveTypeName(fieldEntry.type.ofType))) {
1149
+ } else if (isGraphQLisoDate(getEffectiveTypeName(type.ofType))) {
1116
1150
  schemaArg[fieldEntryName] = [Date];
1117
1151
  }
1118
- } else if (isGraphQLisoDate(getEffectiveTypeName(fieldEntry.type))
1119
- || (fieldEntry.type instanceof GraphQLNonNull && isGraphQLisoDate(getEffectiveTypeName(fieldEntry.type.ofType)))) {
1152
+ } else if (isGraphQLisoDate(getEffectiveTypeName(unwrapNonNull(type)))) {
1120
1153
  schemaArg[fieldEntryName] = Date;
1121
1154
  }
1122
1155
  }
@@ -1126,388 +1159,230 @@ const generateSchemaDefinition = (gqlType) => {
1126
1159
 
1127
1160
  const findObjectIdFields = (schemaDefinition, parentPath = '') => {
1128
1161
  const objectIdFields = [];
1129
-
1162
+
1130
1163
  for (const [fieldName, fieldDefinition] of Object.entries(schemaDefinition)) {
1131
1164
  const currentPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
1132
-
1165
+
1133
1166
  if (fieldDefinition === mongoose.Schema.Types.ObjectId) {
1134
- // Direct ObjectId field
1135
1167
  objectIdFields.push(currentPath);
1136
1168
  } else if (typeof fieldDefinition === 'object' && fieldDefinition !== null) {
1137
1169
  if (Array.isArray(fieldDefinition)) {
1138
- // Array field - check if it's an array of objects
1139
1170
  const arrayElement = fieldDefinition[0];
1140
1171
  if (typeof arrayElement === 'object' && arrayElement !== null) {
1141
- // Array of embedded objects - recursively check for ObjectId fields
1142
- const nestedObjectIdFields = findObjectIdFields(arrayElement, currentPath);
1143
- objectIdFields.push(...nestedObjectIdFields);
1172
+ objectIdFields.push(...findObjectIdFields(arrayElement, currentPath));
1144
1173
  }
1145
1174
  } else if (fieldDefinition.type === mongoose.Schema.Types.ObjectId) {
1146
- // Object with ObjectId type
1147
1175
  objectIdFields.push(currentPath);
1148
- } else if (typeof fieldDefinition === 'object' && !fieldDefinition.type) {
1149
- // Embedded object - recursively check for ObjectId fields
1150
- const nestedObjectIdFields = findObjectIdFields(fieldDefinition, currentPath);
1151
- objectIdFields.push(...nestedObjectIdFields);
1176
+ } else if (!fieldDefinition.type) {
1177
+ objectIdFields.push(...findObjectIdFields(fieldDefinition, currentPath));
1152
1178
  }
1153
1179
  }
1154
1180
  }
1155
-
1181
+
1156
1182
  return objectIdFields;
1157
1183
  };
1158
1184
 
1159
1185
  const createSchemaWithIndexes = (schemaDefinition) => {
1160
1186
  const schema = new mongoose.Schema(schemaDefinition);
1161
-
1162
- // Find all ObjectId fields in the schema
1163
- const objectIdFields = findObjectIdFields(schemaDefinition);
1164
-
1165
- // Create indexes for all ObjectId fields
1166
- objectIdFields.forEach(fieldPath => {
1187
+ findObjectIdFields(schemaDefinition).forEach((fieldPath) => {
1167
1188
  schema.index({ [fieldPath]: 1 });
1168
1189
  });
1169
-
1170
1190
  return schema;
1171
1191
  };
1172
1192
 
1173
- const generateModel = (gqlType, onModelCreated) => {
1193
+ const generateModel = (gqlType, onModelCreated, { createCollection = true } = {}) => {
1174
1194
  const schemaDefinition = generateSchemaDefinition(gqlType);
1175
1195
  const schema = createSchemaWithIndexes(schemaDefinition);
1176
1196
  const model = mongoose.model(gqlType.name, schema, gqlType.name);
1177
1197
  if (onModelCreated) {
1178
1198
  onModelCreated(model);
1179
1199
  }
1180
- if (!preventCollectionCreation) {
1200
+ if (createCollection && !preventCollectionCreation) {
1181
1201
  model.createCollection();
1182
1202
  }
1183
1203
  return model;
1184
1204
  };
1185
1205
 
1186
- const generateModelWithoutCollection = (gqlType, onModelCreated) => {
1187
- const schemaDefinition = generateSchemaDefinition(gqlType);
1188
- const schema = createSchemaWithIndexes(schemaDefinition);
1189
- const model = mongoose.model(gqlType.name, schema, gqlType.name);
1190
- if (onModelCreated) {
1191
- onModelCreated(model);
1206
+ const coerceIdArray = (value) => value.map((element) => {
1207
+ if (element === null || element === undefined) {
1208
+ throw new SimfinityError('ID value cannot be null in collection filter', 'INVALID_FILTER_VALUE', 400);
1192
1209
  }
1193
- // Never create collection for no-endpoint types
1194
- return model;
1195
- };
1210
+ return new mongoose.Types.ObjectId(element);
1211
+ });
1196
1212
 
1197
1213
  const buildMatchesClause = (fieldname, operator, value) => {
1198
- const matches = {};
1199
- if (operator === QLOperator.getValue('EQ').value || !operator) {
1200
- let fixedValue = value;
1201
- if (fieldname.endsWith('_id')) {
1202
- fixedValue = new mongoose.Types.ObjectId(value);
1203
- }
1204
- matches[fieldname] = fixedValue;
1205
- } else if (operator === QLOperator.getValue('LT').value) {
1206
- matches[fieldname] = { $lt: value };
1207
- } else if (operator === QLOperator.getValue('GT').value) {
1208
- matches[fieldname] = { $gt: value };
1209
- } else if (operator === QLOperator.getValue('LTE').value) {
1210
- matches[fieldname] = { $lte: value };
1211
- } else if (operator === QLOperator.getValue('GTE').value) {
1212
- matches[fieldname] = { $gte: value };
1213
- } else if (operator === QLOperator.getValue('NE').value) {
1214
- matches[fieldname] = { $ne: value };
1215
- } else if (operator === QLOperator.getValue('BTW').value) {
1216
- matches[fieldname] = { $gte: value[0], $lte: value[1] };
1217
- } else if (operator === QLOperator.getValue('IN').value) {
1218
- let fixedArray = value;
1219
- if (value && fieldname.endsWith('_id')) {
1220
- fixedArray = [];
1221
- value.forEach((element) => {
1222
- fixedArray.push(new mongoose.Types.ObjectId(element));
1223
- });
1224
- }
1225
- matches[fieldname] = { $in: fixedArray };
1226
- } else if (operator === QLOperator.getValue('NIN').value) {
1227
- let fixedArray = value;
1228
- if (value && fieldname.endsWith('_id')) {
1229
- fixedArray = [];
1230
- value.forEach((element) => {
1231
- fixedArray.push(new mongoose.Types.ObjectId(element));
1232
- });
1214
+ const isIdField = fieldname.endsWith('_id');
1215
+ const op = operator || 'EQ';
1216
+
1217
+ if (op === 'EQ') {
1218
+ return { [fieldname]: isIdField ? new mongoose.Types.ObjectId(value) : value };
1219
+ }
1220
+ if (op === 'IN' || op === 'NIN') {
1221
+ if (!Array.isArray(value)) {
1222
+ throw new SimfinityError(`${op} requires an array value for ${fieldname}`, 'INVALID_FILTER_VALUE', 400);
1233
1223
  }
1234
- matches[fieldname] = { $nin: fixedArray };
1235
- } else if (operator === QLOperator.getValue('LIKE').value) {
1236
- matches[fieldname] = { $regex: `.*${value}.*` };
1224
+ const coerced = isIdField ? coerceIdArray(value) : value;
1225
+ return { [fieldname]: { [op === 'IN' ? '$in' : '$nin']: coerced } };
1237
1226
  }
1227
+ const builder = OP_TO_MONGO[op];
1228
+ return builder ? { [fieldname]: builder(value) } : {};
1229
+ };
1238
1230
 
1239
- return matches;
1231
+ const topLevelRelationLookup = (qlField, fieldType, fieldName) => {
1232
+ const { collectionName } = typesDict.types[fieldType.name].model.collection;
1233
+ const connField = qlField.extensions?.relation?.connectionField || fieldName;
1234
+ const isList = qlField.type instanceof GraphQLList;
1235
+ return buildRelationLookup({
1236
+ collectionName,
1237
+ localField: isList ? '_id' : connField,
1238
+ foreignField: isList ? connField : '_id',
1239
+ alias: fieldName,
1240
+ });
1241
+ };
1242
+
1243
+ const nestedRelationLookup = (pathField, pathFieldType, currentPath, aliasPath, pathFieldName) => {
1244
+ const { collectionName } = typesDict.types[pathFieldType.name].model.collection;
1245
+ const connField = pathField.extensions?.relation?.connectionField || pathFieldName;
1246
+ const isList = pathField.type instanceof GraphQLList;
1247
+ return buildRelationLookup({
1248
+ collectionName,
1249
+ localField: isList ? `${currentPath}._id` : `${currentPath}.${connField}`,
1250
+ foreignField: isList ? connField : '_id',
1251
+ alias: aliasPath,
1252
+ });
1240
1253
  };
1241
1254
 
1242
1255
  const buildAggregationsForSort = (filterField, qlField, fieldName) => {
1243
1256
  const aggregateClauses = {};
1257
+ const fieldType = unwrapListAndNonNull(qlField.type);
1244
1258
 
1245
- let fieldType = qlField.type;
1246
- if (qlField.type instanceof GraphQLList) {
1247
- fieldType = qlField.type.ofType;
1259
+ if (!(fieldType instanceof GraphQLObjectType || isNonNullOfType(fieldType, GraphQLObjectType))) {
1260
+ return aggregateClauses;
1248
1261
  }
1249
- if (fieldType instanceof GraphQLObjectType
1250
- || isNonNullOfType(fieldType, GraphQLObjectType)) {
1251
- if (fieldType instanceof GraphQLNonNull) {
1252
- fieldType = qlField.type.ofType;
1253
- }
1254
- filterField.terms.forEach((term) => {
1255
- if (qlField.extensions && qlField.extensions.relation
1256
- && !qlField.extensions.relation.embedded) {
1257
- const { model } = typesDict.types[fieldType.name];
1258
- const { collectionName } = model.collection;
1259
- const localFieldName = qlField.extensions?.relation?.connectionField || fieldName;
1260
- if (!aggregateClauses[fieldName]) {
1261
- let lookup = {};
1262
-
1263
- if (qlField.type instanceof GraphQLList) {
1264
- lookup = {
1265
- $lookup: {
1266
- from: collectionName,
1267
- foreignField: localFieldName,
1268
- localField: '_id',
1269
- as: fieldName,
1270
- },
1271
- };
1272
- } else {
1273
- lookup = {
1274
- $lookup: {
1275
- from: collectionName,
1276
- foreignField: '_id',
1277
- localField: localFieldName,
1278
- as: fieldName,
1279
- },
1280
- };
1281
- }
1282
1262
 
1283
- aggregateClauses[fieldName] = {
1284
- lookup,
1285
- unwind: { $unwind: { path: `$${fieldName}`, preserveNullAndEmptyArrays: true } },
1286
- };
1287
- }
1288
- }
1263
+ if (!Array.isArray(filterField?.terms) || filterField.terms.length === 0) {
1264
+ return aggregateClauses;
1265
+ }
1289
1266
 
1290
- let currentGQLPathFieldType = qlField.type;
1291
- if (currentGQLPathFieldType instanceof GraphQLList
1292
- || currentGQLPathFieldType instanceof GraphQLNonNull) {
1293
- currentGQLPathFieldType = currentGQLPathFieldType.ofType;
1294
- }
1295
- let aliasPath = fieldName;
1296
- let embeddedPath = '';
1297
-
1298
- term.path.split('.').forEach((pathFieldName) => {
1299
- const pathField = currentGQLPathFieldType.getFields()[pathFieldName];
1300
- if (pathField.type instanceof GraphQLObjectType
1301
- || pathField.type instanceof GraphQLList
1302
- || isNonNullOfType(pathField.type, GraphQLObjectType)) {
1303
- let pathFieldType = pathField.type;
1304
- if (pathField.type instanceof GraphQLList || pathField.type instanceof GraphQLNonNull) {
1305
- pathFieldType = pathField.type.ofType;
1306
- }
1307
- currentGQLPathFieldType = pathFieldType;
1308
- if (pathField.extensions && pathField.extensions.relation
1309
- && !pathField.extensions.relation.embedded) {
1310
- const currentPath = aliasPath + (embeddedPath !== '' ? `.${embeddedPath}` : '');
1311
- aliasPath += (embeddedPath !== '' ? `_${embeddedPath}_` : '_') + pathFieldName;
1312
-
1313
- embeddedPath = '';
1314
-
1315
- const pathModel = typesDict.types[pathFieldType.name].model;
1316
- const fieldPathCollectionName = pathModel.collection.collectionName;
1317
- const pathLocalFieldName = pathField.extensions?.relation?.connectionField || pathFieldName;
1318
-
1319
- if (!aggregateClauses[aliasPath]) {
1320
- let lookup = {};
1321
- if (pathField.type instanceof GraphQLList) {
1322
- lookup = {
1323
- $lookup: {
1324
- from: fieldPathCollectionName,
1325
- foreignField: pathLocalFieldName,
1326
- localField: `${currentPath}._id`,
1327
- as: aliasPath,
1328
- },
1329
- };
1330
- } else {
1331
- lookup = {
1332
- $lookup: {
1333
- from: fieldPathCollectionName,
1334
- foreignField: '_id',
1335
- localField: `${currentPath}.${pathLocalFieldName}`,
1336
- as: aliasPath,
1337
- },
1338
- };
1339
- }
1267
+ const resolvedFieldType = unwrapNonNull(fieldType);
1340
1268
 
1341
- aggregateClauses[aliasPath] = {
1342
- lookup,
1343
- unwind: { $unwind: { path: `$${aliasPath}`, preserveNullAndEmptyArrays: true } },
1344
- };
1345
- }
1346
- } else if (embeddedPath === '') {
1347
- embeddedPath += pathFieldName;
1348
- } else {
1349
- embeddedPath += `.${pathFieldName}`;
1269
+ filterField.terms.forEach((term) => {
1270
+ assertValidFilterPath(term.path);
1271
+ if (qlField.extensions?.relation && !qlField.extensions.relation.embedded
1272
+ && !aggregateClauses[fieldName]) {
1273
+ aggregateClauses[fieldName] = topLevelRelationLookup(qlField, resolvedFieldType, fieldName);
1274
+ }
1275
+
1276
+ let currentGQLPathFieldType = unwrapListAndNonNull(qlField.type);
1277
+ let aliasPath = fieldName;
1278
+ let embeddedPath = '';
1279
+
1280
+ term.path.split('.').forEach((pathFieldName) => {
1281
+ const pathField = currentGQLPathFieldType.getFields()[pathFieldName];
1282
+ if (pathField.type instanceof GraphQLObjectType
1283
+ || pathField.type instanceof GraphQLList
1284
+ || isNonNullOfType(pathField.type, GraphQLObjectType)) {
1285
+ const pathFieldType = unwrapListAndNonNull(pathField.type);
1286
+ currentGQLPathFieldType = pathFieldType;
1287
+
1288
+ if (pathField.extensions?.relation && !pathField.extensions.relation.embedded) {
1289
+ const currentPath = aliasPath + (embeddedPath !== '' ? `.${embeddedPath}` : '');
1290
+ aliasPath += (embeddedPath !== '' ? `_${embeddedPath}_` : '_') + pathFieldName;
1291
+ embeddedPath = '';
1292
+
1293
+ if (!aggregateClauses[aliasPath]) {
1294
+ aggregateClauses[aliasPath] = nestedRelationLookup(
1295
+ pathField, pathFieldType, currentPath, aliasPath, pathFieldName,
1296
+ );
1350
1297
  }
1298
+ } else {
1299
+ embeddedPath = embeddedPath === '' ? pathFieldName : `${embeddedPath}.${pathFieldName}`;
1351
1300
  }
1352
- });
1301
+ }
1353
1302
  });
1354
- }
1303
+ });
1355
1304
  return aggregateClauses;
1356
1305
  };
1357
1306
 
1358
1307
  const buildQueryTerms = async (filterField, qlField, fieldName) => {
1359
1308
  const aggregateClauses = {};
1360
1309
  const matchesClauses = {};
1310
+ const fieldType = unwrapListAndNonNull(qlField.type);
1361
1311
 
1362
- let fieldType = qlField.type;
1363
- if (qlField.type instanceof GraphQLList) {
1364
- fieldType = qlField.type.ofType;
1312
+ if (filterField == null) {
1313
+ return { aggregateClauses, matchesClauses };
1365
1314
  }
1315
+
1366
1316
  if (fieldType instanceof GraphQLScalarType
1367
1317
  || isNonNullOfType(fieldType, GraphQLScalarType)
1368
1318
  || fieldType instanceof GraphQLEnumType
1369
1319
  || isNonNullOfType(fieldType, GraphQLEnumType)) {
1370
- const fieldTypeName = fieldType instanceof GraphQLNonNull ? getEffectiveTypeName(fieldType.ofType) : getEffectiveTypeName(fieldType);
1371
- if (isGraphQLisoDate(fieldTypeName)) {
1372
- if (Array.isArray(filterField.value)) {
1373
- filterField.value = filterField.value.map((value) => value && new Date(value));
1374
- } else {
1375
- filterField.value = filterField.value && new Date(filterField.value);
1376
- }
1377
- }
1320
+ coerceDateValue(fieldType, filterField, 'value');
1378
1321
  matchesClauses[fieldName] = buildMatchesClause(fieldName === 'id' ? '_id' : fieldName, filterField.operator, filterField.value);
1379
- } else if (fieldType instanceof GraphQLObjectType
1380
- || isNonNullOfType(fieldType, GraphQLObjectType)) {
1381
- if (fieldType instanceof GraphQLNonNull) {
1382
- fieldType = qlField.type.ofType;
1383
- }
1322
+ return { aggregateClauses, matchesClauses };
1323
+ }
1384
1324
 
1385
- filterField.terms.forEach((term) => {
1386
- if (qlField.extensions && qlField.extensions.relation
1387
- && !qlField.extensions.relation.embedded) {
1388
- const { model } = typesDict.types[fieldType.name];
1389
- const { collectionName } = model.collection;
1390
- const localFieldName = qlField.extensions?.relation?.connectionField || fieldName;
1391
- if (!aggregateClauses[fieldName]) {
1392
- let lookup = {};
1393
-
1394
- if (qlField.type instanceof GraphQLList) {
1395
- lookup = {
1396
- $lookup: {
1397
- from: collectionName,
1398
- foreignField: localFieldName,
1399
- localField: '_id',
1400
- as: fieldName,
1401
- },
1402
- };
1403
- } else {
1404
- lookup = {
1405
- $lookup: {
1406
- from: collectionName,
1407
- foreignField: '_id',
1408
- localField: localFieldName,
1409
- as: fieldName,
1410
- },
1411
- };
1412
- }
1325
+ if (!(fieldType instanceof GraphQLObjectType || isNonNullOfType(fieldType, GraphQLObjectType))) {
1326
+ return { aggregateClauses, matchesClauses };
1327
+ }
1413
1328
 
1414
- aggregateClauses[fieldName] = {
1415
- lookup,
1416
- unwind: { $unwind: { path: `$${fieldName}`, preserveNullAndEmptyArrays: true } },
1417
- };
1418
- }
1419
- }
1329
+ if (!Array.isArray(filterField.terms) || filterField.terms.length === 0) {
1330
+ return { aggregateClauses, matchesClauses };
1331
+ }
1420
1332
 
1421
- if (term.path.indexOf('.') < 0) {
1422
- const { type } = fieldType.getFields()[term.path];
1423
- const typeName = type instanceof GraphQLNonNull ? getEffectiveTypeName(type.ofType) : getEffectiveTypeName(type);
1424
- if (isGraphQLisoDate(typeName)) {
1425
- if (Array.isArray(term.value)) {
1426
- term.value = term.value.map((value) => value && new Date(value));
1427
- } else {
1428
- term.value = term.value && new Date(term.value);
1429
- }
1430
- }
1431
- matchesClauses[fieldName] = buildMatchesClause(`${fieldName}.${fieldType.getFields()[term.path].name === 'id' ? '_id' : term.path}`, term.operator, term.value);
1432
- } else {
1433
- let currentGQLPathFieldType = qlField.type;
1434
- if (currentGQLPathFieldType instanceof GraphQLList
1435
- || currentGQLPathFieldType instanceof GraphQLNonNull) {
1436
- currentGQLPathFieldType = currentGQLPathFieldType.ofType;
1437
- }
1438
- let aliasPath = fieldName;
1439
- let embeddedPath = '';
1440
-
1441
- term.path.split('.').forEach((pathFieldName) => {
1442
- const pathField = currentGQLPathFieldType.getFields()[pathFieldName];
1443
- if (pathField.type instanceof GraphQLScalarType
1444
- || isNonNullOfType(pathField.type, GraphQLScalarType)) {
1445
- const typeName = pathField.type instanceof GraphQLNonNull ? getEffectiveTypeName(pathField.type.ofType) : getEffectiveTypeName(pathField.type);
1446
- if (isGraphQLisoDate(typeName)) {
1447
- if (Array.isArray(term.value)) {
1448
- term.value = term.value.map((value) => value && new Date(value));
1449
- } else {
1450
- term.value = term.value && new Date(term.value);
1451
- }
1452
- }
1453
- matchesClauses[`${aliasPath}_${pathFieldName}`] = buildMatchesClause(aliasPath + (embeddedPath !== '' ? `.${embeddedPath}.` : '.') + (pathFieldName === 'id' ? '_id' : pathFieldName), term.operator, term.value);
1454
- embeddedPath = '';
1455
- } else if (pathField.type instanceof GraphQLObjectType
1456
- || pathField.type instanceof GraphQLList
1457
- || isNonNullOfType(pathField.type, GraphQLObjectType)) {
1458
- let pathFieldType = pathField.type;
1459
- if (pathField.type instanceof GraphQLList || pathField.type instanceof GraphQLNonNull) {
1460
- pathFieldType = pathField.type.ofType;
1461
- }
1462
- currentGQLPathFieldType = pathFieldType;
1463
- if (pathField.extensions && pathField.extensions.relation
1464
- && !pathField.extensions.relation.embedded) {
1465
- const currentPath = aliasPath + (embeddedPath !== '' ? `.${embeddedPath}` : '');
1466
- aliasPath += (embeddedPath !== '' ? `_${embeddedPath}_` : '_') + pathFieldName;
1467
-
1468
- embeddedPath = '';
1469
-
1470
- const pathModel = typesDict.types[pathFieldType.name].model;
1471
- const fieldPathCollectionName = pathModel.collection.collectionName;
1472
- const pathLocalFieldName = pathField.extensions?.relation?.connectionField || pathFieldName;
1473
-
1474
- if (!aggregateClauses[aliasPath]) {
1475
- let lookup = {};
1476
- if (pathField.type instanceof GraphQLList) {
1477
- lookup = {
1478
- $lookup: {
1479
- from: fieldPathCollectionName,
1480
- foreignField: pathLocalFieldName,
1481
- localField: `${currentPath}._id`,
1482
- as: aliasPath,
1483
- },
1484
- };
1485
- } else {
1486
- lookup = {
1487
- $lookup: {
1488
- from: fieldPathCollectionName,
1489
- foreignField: '_id',
1490
- localField: `${currentPath}.${pathLocalFieldName}`,
1491
- as: aliasPath,
1492
- },
1493
- };
1494
- }
1333
+ const resolvedFieldType = unwrapNonNull(fieldType);
1495
1334
 
1496
- aggregateClauses[aliasPath] = {
1497
- lookup,
1498
- unwind: { $unwind: { path: `$${aliasPath}`, preserveNullAndEmptyArrays: true } },
1499
- };
1500
- }
1501
- } else if (embeddedPath === '') {
1502
- embeddedPath += pathFieldName;
1503
- } else {
1504
- embeddedPath += `.${pathFieldName}`;
1505
- }
1335
+ filterField.terms.forEach((term) => {
1336
+ assertValidFilterPath(term.path);
1337
+ if (qlField.extensions?.relation && !qlField.extensions.relation.embedded
1338
+ && !aggregateClauses[fieldName]) {
1339
+ aggregateClauses[fieldName] = topLevelRelationLookup(qlField, resolvedFieldType, fieldName);
1340
+ }
1341
+
1342
+ if (term.path.indexOf('.') < 0) {
1343
+ const { type: leafType } = resolvedFieldType.getFields()[term.path];
1344
+ coerceDateValue(leafType, term, 'value');
1345
+ const leafName = resolvedFieldType.getFields()[term.path].name === 'id' ? '_id' : term.path;
1346
+ matchesClauses[fieldName] = buildMatchesClause(`${fieldName}.${leafName}`, term.operator, term.value);
1347
+ return;
1348
+ }
1349
+
1350
+ let currentGQLPathFieldType = unwrapListAndNonNull(qlField.type);
1351
+ let aliasPath = fieldName;
1352
+ let embeddedPath = '';
1353
+
1354
+ term.path.split('.').forEach((pathFieldName) => {
1355
+ const pathField = currentGQLPathFieldType.getFields()[pathFieldName];
1356
+ if (pathField.type instanceof GraphQLScalarType
1357
+ || isNonNullOfType(pathField.type, GraphQLScalarType)) {
1358
+ coerceDateValue(pathField.type, term, 'value');
1359
+ const leafName = pathFieldName === 'id' ? '_id' : pathFieldName;
1360
+ const mongoPath = aliasPath + (embeddedPath !== '' ? `.${embeddedPath}.` : '.') + leafName;
1361
+ matchesClauses[`${aliasPath}_${pathFieldName}`] = buildMatchesClause(mongoPath, term.operator, term.value);
1362
+ embeddedPath = '';
1363
+ } else if (pathField.type instanceof GraphQLObjectType
1364
+ || pathField.type instanceof GraphQLList
1365
+ || isNonNullOfType(pathField.type, GraphQLObjectType)) {
1366
+ const pathFieldType = unwrapListAndNonNull(pathField.type);
1367
+ currentGQLPathFieldType = pathFieldType;
1368
+
1369
+ if (pathField.extensions?.relation && !pathField.extensions.relation.embedded) {
1370
+ const currentPath = aliasPath + (embeddedPath !== '' ? `.${embeddedPath}` : '');
1371
+ aliasPath += (embeddedPath !== '' ? `_${embeddedPath}_` : '_') + pathFieldName;
1372
+ embeddedPath = '';
1373
+
1374
+ if (!aggregateClauses[aliasPath]) {
1375
+ aggregateClauses[aliasPath] = nestedRelationLookup(
1376
+ pathField, pathFieldType, currentPath, aliasPath, pathFieldName,
1377
+ );
1506
1378
  }
1507
- });
1379
+ } else {
1380
+ embeddedPath = embeddedPath === '' ? pathFieldName : `${embeddedPath}.${pathFieldName}`;
1381
+ }
1508
1382
  }
1509
1383
  });
1510
- }
1384
+ });
1385
+
1511
1386
  return { aggregateClauses, matchesClauses };
1512
1387
  };
1513
1388
 
@@ -1521,93 +1396,57 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
1521
1396
  const parts = [];
1522
1397
  const fields = gqltype.getFields();
1523
1398
 
1524
- // Process leaf conditions
1525
- if (filterGroup.conditions && filterGroup.conditions.length > 0) {
1399
+ if (filterGroup.conditions?.length > 0) {
1526
1400
  for (const condition of filterGroup.conditions) {
1527
1401
  const qlField = fields[condition.field];
1528
1402
  if (!qlField) {
1529
- throw new SimfinityError(
1530
- `Unknown filter field: ${condition.field}`,
1531
- 'INVALID_FILTER_FIELD',
1532
- 400,
1533
- );
1403
+ throw new SimfinityError(`Unknown filter field: ${condition.field}`, 'INVALID_FILTER_FIELD', 400);
1534
1404
  }
1535
1405
 
1536
- let filterInput;
1537
- let fieldType = qlField.type;
1538
- if (fieldType instanceof GraphQLList || fieldType instanceof GraphQLNonNull) {
1539
- fieldType = fieldType.ofType;
1540
- }
1406
+ const fieldType = unwrapListAndNonNull(qlField.type);
1407
+ const isObject = fieldType instanceof GraphQLObjectType || isNonNullOfType(fieldType, GraphQLObjectType);
1541
1408
 
1542
- if (fieldType instanceof GraphQLObjectType
1543
- || isNonNullOfType(fieldType, GraphQLObjectType)) {
1544
- // Object/relation field — wrap as QLTypeFilterExpression shape
1409
+ let filterInput;
1410
+ if (isObject) {
1545
1411
  if (!condition.path) {
1546
- throw new SimfinityError(
1547
- `Filter on object field "${condition.field}" requires a path`,
1548
- 'MISSING_FILTER_PATH',
1549
- 400,
1550
- );
1412
+ throw new SimfinityError(`Filter on object field "${condition.field}" requires a path`, 'MISSING_FILTER_PATH', 400);
1551
1413
  }
1552
1414
  filterInput = {
1553
- terms: [{
1554
- path: condition.path,
1555
- operator: condition.operator,
1556
- value: condition.value,
1557
- }],
1415
+ terms: [{ path: condition.path, operator: condition.operator, value: condition.value }],
1558
1416
  };
1559
1417
  } else {
1560
- // Scalar/enum field
1561
- filterInput = {
1562
- operator: condition.operator,
1563
- value: condition.value,
1564
- };
1418
+ filterInput = { operator: condition.operator, value: condition.value };
1565
1419
  }
1566
1420
 
1567
1421
  const result = await buildQueryTerms(filterInput, qlField, condition.field);
1422
+ if (!result) continue;
1568
1423
 
1569
- if (result) {
1570
- // Collect lookups (deduplicated)
1571
- for (const [prop, aggregate] of Object.entries(result.aggregateClauses)) {
1572
- if (!aggregationsIncluded[prop]) {
1573
- aggregateClauses.push(aggregate.lookup);
1574
- aggregateClauses.push(aggregate.unwind);
1575
- aggregationsIncluded[prop] = true;
1576
- }
1424
+ for (const [prop, aggregate] of Object.entries(result.aggregateClauses)) {
1425
+ if (!aggregationsIncluded[prop]) {
1426
+ aggregateClauses.push(aggregate.lookup, aggregate.unwind);
1427
+ aggregationsIncluded[prop] = true;
1577
1428
  }
1578
-
1579
- // Collect match conditions
1580
- for (const matchClause of Object.values(result.matchesClauses)) {
1581
- for (const [matchKey, match] of Object.entries(matchClause)) {
1582
- parts.push({ [matchKey]: match });
1583
- }
1429
+ }
1430
+ for (const matchClause of Object.values(result.matchesClauses)) {
1431
+ for (const [matchKey, match] of Object.entries(matchClause)) {
1432
+ parts.push({ [matchKey]: match });
1584
1433
  }
1585
1434
  }
1586
1435
  }
1587
1436
  }
1588
1437
 
1589
- // Process AND sub-groups
1590
- if (filterGroup.AND && filterGroup.AND.length > 0) {
1438
+ if (filterGroup.AND?.length > 0) {
1591
1439
  for (const subGroup of filterGroup.AND) {
1592
- const subMatch = await buildFilterGroupMatch(
1593
- subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1,
1594
- );
1595
- if (subMatch) {
1596
- parts.push(subMatch);
1597
- }
1440
+ const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
1441
+ if (subMatch) parts.push(subMatch);
1598
1442
  }
1599
1443
  }
1600
1444
 
1601
- // Process OR sub-groups
1602
- if (filterGroup.OR && filterGroup.OR.length > 0) {
1445
+ if (filterGroup.OR?.length > 0) {
1603
1446
  const orParts = [];
1604
1447
  for (const subGroup of filterGroup.OR) {
1605
- const subMatch = await buildFilterGroupMatch(
1606
- subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1,
1607
- );
1608
- if (subMatch) {
1609
- orParts.push(subMatch);
1610
- }
1448
+ const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
1449
+ if (subMatch) orParts.push(subMatch);
1611
1450
  }
1612
1451
  if (orParts.length === 1) {
1613
1452
  parts.push(orParts[0]);
@@ -1623,105 +1462,44 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
1623
1462
 
1624
1463
  const RESERVED_QUERY_KEYS = new Set(['pagination', 'sort', 'AND', 'OR', 'aggregation']);
1625
1464
 
1626
- const buildQuery = async (input, gqltype, isCount) => {
1627
- const aggregateClauses = [];
1465
+ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggregationsIncluded) => {
1628
1466
  const flatMatchConditions = {};
1629
1467
  let hasFlat = false;
1630
- let limitClause = { $limit: 100 };
1631
- let skipClause = { $skip: 0 };
1632
- let sortClause = {};
1633
- let addSort = false;
1634
- const aggregationsIncluded = {};
1468
+ const fields = gqltype.getFields();
1635
1469
 
1636
1470
  for (const [key, filterField] of Object.entries(input)) {
1637
- if (Object.prototype.hasOwnProperty.call(input, key) && !RESERVED_QUERY_KEYS.has(key)) {
1638
- const qlField = gqltype.getFields()[key];
1639
-
1640
- const result = await buildQueryTerms(filterField, qlField, key);
1641
-
1642
- if (result) {
1643
- for (const [prop, aggregate] of Object.entries(result.aggregateClauses)) {
1644
- aggregateClauses.push(aggregate.lookup);
1645
- aggregateClauses.push(aggregate.unwind);
1646
- aggregationsIncluded[prop] = true;
1647
- }
1648
-
1649
- for (const [matchClauseKey, matchClause] of Object.entries(result.matchesClauses)) {
1650
- if (Object.prototype.hasOwnProperty.call(result.matchesClauses, matchClauseKey)) {
1651
- for (const [matchKey, match] of Object.entries(matchClause)) {
1652
- if (Object.prototype.hasOwnProperty.call(matchClause, matchKey)) {
1653
- flatMatchConditions[matchKey] = match;
1654
- hasFlat = true;
1655
- }
1656
- }
1657
- }
1658
- }
1659
- }
1660
- } else if (key === 'pagination') {
1661
- if (filterField.page && filterField.size) {
1662
- const skip = filterField.size * (filterField.page - 1);
1663
- limitClause = { $limit: filterField.size + skip };
1664
- skipClause = { $skip: skip };
1471
+ if (RESERVED_QUERY_KEYS.has(key)) continue;
1472
+ const qlField = fields[key];
1473
+ const result = await buildQueryTerms(filterField, qlField, key);
1474
+ if (!result) continue;
1475
+
1476
+ for (const [prop, aggregate] of Object.entries(result.aggregateClauses)) {
1477
+ aggregateClauses.push(aggregate.lookup, aggregate.unwind);
1478
+ aggregationsIncluded[prop] = true;
1479
+ }
1480
+ for (const matchClause of Object.values(result.matchesClauses)) {
1481
+ for (const [matchKey, match] of Object.entries(matchClause)) {
1482
+ flatMatchConditions[matchKey] = match;
1483
+ hasFlat = true;
1665
1484
  }
1666
- } else if (key === 'sort') {
1667
- const sortExpressions = {};
1668
- filterField.terms.forEach((sort) => {
1669
- let fixedSortField = sort.field;
1670
-
1671
- if (sort.field.indexOf('.') >= 0) {
1672
- const sortParts = sort.field.split('.');
1673
-
1674
- fixedSortField = sortParts[0];
1675
-
1676
- for (let i = 1; i < sortParts.length - 1; i++) {
1677
- fixedSortField += `_${sortParts[i]}`;
1678
- }
1679
- fixedSortField += `.${sortParts[sortParts.length - 1]}`;
1680
- const qlField = gqltype.getFields()[sortParts[0]];
1681
- const path = sort.field.slice(sort.field.indexOf('.') + 1);
1682
- const aggreagtionsForSort = buildAggregationsForSort({ terms: [{ path }] }, qlField, sortParts[0]);
1683
- for (const [prop, aggregate] of Object.entries(aggreagtionsForSort)) {
1684
- if (!aggregationsIncluded[prop]) {
1685
- aggregateClauses.push(aggregate.lookup);
1686
- aggregateClauses.push(aggregate.unwind);
1687
- }
1688
- }
1689
- }
1690
-
1691
- sortExpressions[fixedSortField] = sort.order === 'ASC' ? 1 : -1;
1692
- });
1693
- sortClause = { $sort: sortExpressions };
1694
- addSort = true;
1695
1485
  }
1696
1486
  }
1697
1487
 
1698
- // Combine flat conditions with AND/OR groups
1699
1488
  const topLevelAndParts = [];
1489
+ if (hasFlat) topLevelAndParts.push(flatMatchConditions);
1700
1490
 
1701
- if (hasFlat) {
1702
- topLevelAndParts.push(flatMatchConditions);
1703
- }
1704
-
1705
- if (input.AND && input.AND.length > 0) {
1491
+ if (input.AND?.length > 0) {
1706
1492
  for (const group of input.AND) {
1707
- const groupMatch = await buildFilterGroupMatch(
1708
- group, gqltype, aggregateClauses, aggregationsIncluded,
1709
- );
1710
- if (groupMatch) {
1711
- topLevelAndParts.push(groupMatch);
1712
- }
1493
+ const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
1494
+ if (groupMatch) topLevelAndParts.push(groupMatch);
1713
1495
  }
1714
1496
  }
1715
1497
 
1716
- if (input.OR && input.OR.length > 0) {
1498
+ if (input.OR?.length > 0) {
1717
1499
  const orParts = [];
1718
1500
  for (const group of input.OR) {
1719
- const groupMatch = await buildFilterGroupMatch(
1720
- group, gqltype, aggregateClauses, aggregationsIncluded,
1721
- );
1722
- if (groupMatch) {
1723
- orParts.push(groupMatch);
1724
- }
1501
+ const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
1502
+ if (groupMatch) orParts.push(groupMatch);
1725
1503
  }
1726
1504
  if (orParts.length === 1) {
1727
1505
  topLevelAndParts.push(orParts[0]);
@@ -1730,310 +1508,170 @@ const buildQuery = async (input, gqltype, isCount) => {
1730
1508
  }
1731
1509
  }
1732
1510
 
1733
- if (topLevelAndParts.length === 1) {
1734
- aggregateClauses.push({ $match: topLevelAndParts[0] });
1735
- } else if (topLevelAndParts.length > 1) {
1736
- aggregateClauses.push({ $match: { $and: topLevelAndParts } });
1737
- }
1511
+ if (topLevelAndParts.length === 1) return { $match: topLevelAndParts[0] };
1512
+ if (topLevelAndParts.length > 1) return { $match: { $and: topLevelAndParts } };
1513
+ return null;
1514
+ };
1738
1515
 
1739
- if (addSort && !isCount) {
1740
- aggregateClauses.push(sortClause);
1741
- }
1516
+ const buildSortClause = (sortTerms, gqltype, aggregateClauses, aggregationsIncluded) => {
1517
+ const sortExpressions = {};
1518
+ const fields = gqltype.getFields();
1742
1519
 
1743
- if (!isCount) {
1744
- aggregateClauses.push(limitClause);
1745
- aggregateClauses.push(skipClause);
1746
- }
1520
+ sortTerms.forEach((sort) => {
1521
+ assertValidFilterPath(sort.field);
1522
+ let fixedSortField = sort.field;
1523
+ if (sort.field.indexOf('.') >= 0) {
1524
+ const sortParts = sort.field.split('.');
1525
+ fixedSortField = sortParts[0];
1526
+ for (let i = 1; i < sortParts.length - 1; i++) {
1527
+ fixedSortField += `_${sortParts[i]}`;
1528
+ }
1529
+ fixedSortField += `.${sortParts[sortParts.length - 1]}`;
1530
+ const qlField = fields[sortParts[0]];
1531
+ const path = sort.field.slice(sort.field.indexOf('.') + 1);
1532
+ const sortAggregations = buildAggregationsForSort({ terms: [{ path }] }, qlField, sortParts[0]);
1533
+ for (const [prop, aggregate] of Object.entries(sortAggregations)) {
1534
+ if (!aggregationsIncluded[prop]) {
1535
+ aggregateClauses.push(aggregate.lookup, aggregate.unwind);
1536
+ }
1537
+ }
1538
+ }
1539
+ sortExpressions[fixedSortField] = sort.order === 'ASC' ? 1 : -1;
1540
+ });
1541
+
1542
+ return { $sort: sortExpressions };
1543
+ };
1544
+
1545
+ const buildQuery = async (input, gqltype, isCount) => {
1546
+ const aggregateClauses = [];
1547
+ const aggregationsIncluded = {};
1548
+
1549
+ const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
1550
+ if (matchStage) aggregateClauses.push(matchStage);
1747
1551
 
1748
1552
  if (isCount) {
1749
1553
  aggregateClauses.push({ $count: 'size' });
1554
+ return aggregateClauses;
1750
1555
  }
1751
1556
 
1557
+ if (input.sort) {
1558
+ aggregateClauses.push(buildSortClause(input.sort.terms, gqltype, aggregateClauses, aggregationsIncluded));
1559
+ }
1560
+
1561
+ let skipClause = { $skip: 0 };
1562
+ let limitClause = { $limit: 100 };
1563
+ if (input.pagination?.page && input.pagination?.size) {
1564
+ skipClause = { $skip: input.pagination.size * (input.pagination.page - 1) };
1565
+ limitClause = { $limit: input.pagination.size };
1566
+ }
1567
+ aggregateClauses.push(skipClause, limitClause);
1568
+
1752
1569
  return aggregateClauses;
1753
1570
  };
1754
1571
 
1755
1572
  const buildFieldPath = (gqltype, fieldPath) => {
1756
- // This function resolves a field path (e.g., "category" or "country.name")
1757
- // and returns the MongoDB field path and any necessary lookups
1573
+ assertValidFilterPath(fieldPath);
1758
1574
  const pathParts = fieldPath.split('.');
1759
- const aggregateClauses = [];
1575
+ const lookupPairs = [];
1760
1576
  let currentPath = '';
1761
1577
  let currentGQLType = gqltype;
1762
-
1763
- for (let i = 0; i < pathParts.length; i++) {
1764
- const part = pathParts[i];
1578
+
1579
+ for (const part of pathParts) {
1765
1580
  const field = currentGQLType.getFields()[part];
1766
-
1767
1581
  if (!field) {
1768
1582
  throw new Error(`Field ${part} not found in type ${currentGQLType.name}`);
1769
1583
  }
1770
-
1771
- let fieldType = field.type;
1772
- if (fieldType instanceof GraphQLNonNull || fieldType instanceof GraphQLList) {
1773
- fieldType = fieldType.ofType;
1774
- }
1775
-
1776
- // If it's an object type with non-embedded relation, we need a lookup
1777
- if ((fieldType instanceof GraphQLObjectType) &&
1778
- field.extensions && field.extensions.relation &&
1779
- !field.extensions.relation.embedded) {
1780
-
1781
- const relatedModel = typesDict.types[fieldType.name].model;
1782
- const collectionName = relatedModel.collection.collectionName;
1783
- const connectionField = field.extensions.relation.connectionField || part;
1784
-
1584
+
1585
+ const fieldType = unwrapListAndNonNull(field.type);
1586
+ const relation = field.extensions?.relation;
1587
+
1588
+ if (fieldType instanceof GraphQLObjectType && relation && !relation.embedded) {
1589
+ const { collectionName } = typesDict.types[fieldType.name].model.collection;
1590
+ const connField = relation.connectionField || part;
1785
1591
  const lookupAlias = currentPath ? `${currentPath}_${part}` : part;
1786
- const localField = currentPath ? `${currentPath}.${connectionField}` : connectionField;
1787
-
1788
- aggregateClauses.push({
1789
- $lookup: {
1790
- from: collectionName,
1791
- foreignField: '_id',
1792
- localField,
1793
- as: lookupAlias,
1794
- },
1795
- });
1796
-
1797
- aggregateClauses.push({
1798
- $unwind: { path: `$${lookupAlias}`, preserveNullAndEmptyArrays: true },
1799
- });
1800
-
1592
+ const localField = currentPath ? `${currentPath}.${connField}` : connField;
1593
+
1594
+ lookupPairs.push(buildRelationLookup({
1595
+ collectionName, localField, foreignField: '_id', alias: lookupAlias,
1596
+ }));
1597
+
1801
1598
  currentPath = lookupAlias;
1802
1599
  currentGQLType = fieldType;
1803
- } else if (fieldType instanceof GraphQLObjectType &&
1804
- field.extensions && field.extensions.relation &&
1805
- field.extensions.relation.embedded) {
1806
- // Embedded object - just append to path
1600
+ } else if (fieldType instanceof GraphQLObjectType && relation?.embedded) {
1807
1601
  currentPath = currentPath ? `${currentPath}.${part}` : part;
1808
1602
  currentGQLType = fieldType;
1809
1603
  } else {
1810
- // Scalar field - final part of path
1811
- if (part === 'id') {
1812
- currentPath = currentPath ? `${currentPath}._id` : '_id';
1813
- } else {
1814
- currentPath = currentPath ? `${currentPath}.${part}` : part;
1815
- }
1604
+ const leaf = part === 'id' ? '_id' : part;
1605
+ currentPath = currentPath ? `${currentPath}.${leaf}` : leaf;
1606
+ }
1607
+ }
1608
+
1609
+ return { mongoPath: currentPath, lookupPairs };
1610
+ };
1611
+
1612
+ const appendLookupPairs = (aggregateClauses, aggregationsIncluded, lookupPairs) => {
1613
+ for (const { lookup, unwind } of lookupPairs) {
1614
+ const alias = lookup.$lookup.as;
1615
+ if (!aggregationsIncluded[alias]) {
1616
+ aggregateClauses.push(lookup, unwind);
1617
+ aggregationsIncluded[alias] = true;
1816
1618
  }
1817
1619
  }
1818
-
1819
- return { mongoPath: currentPath, lookups: aggregateClauses };
1820
1620
  };
1821
1621
 
1822
1622
  const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
1823
1623
  const aggregateClauses = [];
1824
- const flatMatchConditions = {};
1825
- let hasFlat = false;
1826
1624
  const aggregationsIncluded = {};
1827
- const sortTerms = []; // Store multiple sort terms
1828
- let limitClause = null;
1829
- let skipClause = null;
1830
-
1831
- // Build filter and lookup clauses (similar to buildQuery)
1832
- for (const [key, filterField] of Object.entries(input)) {
1833
- if (Object.prototype.hasOwnProperty.call(input, key) && !RESERVED_QUERY_KEYS.has(key)) {
1834
- const qlField = gqltype.getFields()[key];
1835
1625
 
1836
- const result = await buildQueryTerms(filterField, qlField, key);
1837
-
1838
- if (result) {
1839
- for (const [prop, aggregate] of Object.entries(result.aggregateClauses)) {
1840
- aggregateClauses.push(aggregate.lookup);
1841
- aggregateClauses.push(aggregate.unwind);
1842
- aggregationsIncluded[prop] = true;
1843
- }
1626
+ const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
1627
+ if (matchStage) aggregateClauses.push(matchStage);
1844
1628
 
1845
- for (const [matchClauseKey, matchClause] of Object.entries(result.matchesClauses)) {
1846
- if (Object.prototype.hasOwnProperty.call(result.matchesClauses, matchClauseKey)) {
1847
- for (const [matchKey, match] of Object.entries(matchClause)) {
1848
- if (Object.prototype.hasOwnProperty.call(matchClause, matchKey)) {
1849
- flatMatchConditions[matchKey] = match;
1850
- hasFlat = true;
1851
- }
1852
- }
1853
- }
1854
- }
1855
- }
1856
- } else if (key === 'sort' && filterField && filterField.terms && filterField.terms.length > 0) {
1857
- // Extract all sort terms
1858
- filterField.terms.forEach(sortTerm => {
1859
- sortTerms.push({
1860
- field: sortTerm.field || 'groupId',
1861
- direction: sortTerm.order === 'ASC' ? 1 : -1,
1862
- });
1863
- });
1864
- } else if (key === 'pagination' && filterField) {
1865
- // Handle pagination (ignore count parameter)
1866
- if (filterField.page && filterField.size) {
1867
- const skip = filterField.size * (filterField.page - 1);
1868
- limitClause = { $limit: filterField.size + skip };
1869
- skipClause = { $skip: skip };
1870
- }
1871
- }
1872
- }
1873
-
1874
- // Combine flat conditions with AND/OR groups
1875
- const topLevelAndParts = [];
1876
-
1877
- if (hasFlat) {
1878
- topLevelAndParts.push(flatMatchConditions);
1879
- }
1629
+ const { groupId, facts } = aggregationExpression;
1630
+ const groupIdPath = buildFieldPath(gqltype, groupId);
1631
+ appendLookupPairs(aggregateClauses, aggregationsIncluded, groupIdPath.lookupPairs);
1880
1632
 
1881
- if (input.AND && input.AND.length > 0) {
1882
- for (const group of input.AND) {
1883
- const groupMatch = await buildFilterGroupMatch(
1884
- group, gqltype, aggregateClauses, aggregationsIncluded,
1885
- );
1886
- if (groupMatch) {
1887
- topLevelAndParts.push(groupMatch);
1888
- }
1889
- }
1890
- }
1633
+ const groupStage = { $group: { _id: `$${groupIdPath.mongoPath}` } };
1891
1634
 
1892
- if (input.OR && input.OR.length > 0) {
1893
- const orParts = [];
1894
- for (const group of input.OR) {
1895
- const groupMatch = await buildFilterGroupMatch(
1896
- group, gqltype, aggregateClauses, aggregationsIncluded,
1897
- );
1898
- if (groupMatch) {
1899
- orParts.push(groupMatch);
1900
- }
1901
- }
1902
- if (orParts.length === 1) {
1903
- topLevelAndParts.push(orParts[0]);
1904
- } else if (orParts.length > 1) {
1905
- topLevelAndParts.push({ $or: orParts });
1906
- }
1907
- }
1635
+ facts.forEach((fact) => {
1636
+ const factPath = buildFieldPath(gqltype, fact.path);
1637
+ appendLookupPairs(aggregateClauses, aggregationsIncluded, factPath.lookupPairs);
1908
1638
 
1909
- if (topLevelAndParts.length === 1) {
1910
- aggregateClauses.push({ $match: topLevelAndParts[0] });
1911
- } else if (topLevelAndParts.length > 1) {
1912
- aggregateClauses.push({ $match: { $and: topLevelAndParts } });
1913
- }
1914
-
1915
- // Now build the aggregation with $group
1916
- const { groupId, facts } = aggregationExpression;
1917
-
1918
- // Resolve the groupId field path
1919
- const groupIdPath = buildFieldPath(gqltype, groupId);
1920
-
1921
- // Add any lookups needed for the groupId field
1922
- groupIdPath.lookups.forEach(lookup => {
1923
- const lookupKey = Object.keys(lookup)[0];
1924
- const lookupAlias = lookup[lookupKey].as;
1925
- if (!aggregationsIncluded[lookupAlias]) {
1926
- aggregateClauses.push(lookup);
1927
- // Check if next item is an unwind for this lookup
1928
- const unwindItem = groupIdPath.lookups[groupIdPath.lookups.indexOf(lookup) + 1];
1929
- if (unwindItem && unwindItem.$unwind) {
1930
- aggregateClauses.push(unwindItem);
1931
- }
1932
- aggregationsIncluded[lookupAlias] = true;
1933
- }
1934
- });
1935
-
1936
- // Build the $group stage
1937
- const groupStage = {
1938
- $group: {
1939
- _id: `$${groupIdPath.mongoPath}`,
1940
- },
1941
- };
1942
-
1943
- // Add aggregation operations for each fact
1944
- facts.forEach(fact => {
1945
- const { operation, factName, path } = fact;
1946
- const factPath = buildFieldPath(gqltype, path);
1947
-
1948
- // Add any lookups needed for the fact field
1949
- factPath.lookups.forEach(lookup => {
1950
- const lookupKey = Object.keys(lookup)[0];
1951
- const lookupAlias = lookup[lookupKey].as;
1952
- if (!aggregationsIncluded[lookupAlias]) {
1953
- aggregateClauses.push(lookup);
1954
- // Check if next item is an unwind for this lookup
1955
- const unwindItem = factPath.lookups[factPath.lookups.indexOf(lookup) + 1];
1956
- if (unwindItem && unwindItem.$unwind) {
1957
- aggregateClauses.push(unwindItem);
1958
- }
1959
- aggregationsIncluded[lookupAlias] = true;
1960
- }
1961
- });
1962
-
1963
- // Map GraphQL operations to MongoDB aggregation operators
1964
- let mongoOperation;
1965
- switch (operation) {
1966
- case 'SUM':
1967
- mongoOperation = { $sum: `$${factPath.mongoPath}` };
1968
- break;
1969
- case 'COUNT':
1970
- mongoOperation = { $sum: 1 };
1971
- break;
1972
- case 'AVG':
1973
- mongoOperation = { $avg: `$${factPath.mongoPath}` };
1974
- break;
1975
- case 'MIN':
1976
- mongoOperation = { $min: `$${factPath.mongoPath}` };
1977
- break;
1978
- case 'MAX':
1979
- mongoOperation = { $max: `$${factPath.mongoPath}` };
1980
- break;
1981
- default:
1982
- throw new Error(`Unknown aggregation operation: ${operation}`);
1639
+ const builder = AGG_OP_TO_MONGO[fact.operation];
1640
+ if (!builder) {
1641
+ throw new Error(`Unknown aggregation operation: ${fact.operation}`);
1983
1642
  }
1984
-
1985
- groupStage.$group[factName] = mongoOperation;
1643
+ groupStage.$group[fact.factName] = builder(factPath.mongoPath);
1986
1644
  });
1987
-
1645
+
1988
1646
  aggregateClauses.push(groupStage);
1989
-
1990
- // Add a final projection stage to format the output
1647
+
1991
1648
  aggregateClauses.push({
1992
1649
  $project: {
1993
1650
  _id: 0,
1994
1651
  groupId: '$_id',
1995
- facts: Object.fromEntries(facts.map(fact => [fact.factName, `$${fact.factName}`])),
1652
+ facts: Object.fromEntries(facts.map((fact) => [fact.factName, `$${fact.factName}`])),
1996
1653
  },
1997
1654
  });
1998
-
1999
- // Build sort object from multiple sort terms
2000
- if (sortTerms.length > 0) {
1655
+
1656
+ const sortTerms = input.sort?.terms?.length > 0 ? input.sort.terms : null;
1657
+ if (sortTerms) {
1658
+ const factNames = facts.map((fact) => fact.factName);
2001
1659
  const sortObject = {};
2002
- const factNames = facts.map(fact => fact.factName);
2003
-
2004
- sortTerms.forEach(sortTerm => {
2005
- let sortFieldPath = 'groupId';
2006
-
2007
- if (sortTerm.field !== 'groupId') {
2008
- // Check if the field is one of the fact names
2009
- if (factNames.includes(sortTerm.field)) {
2010
- sortFieldPath = `facts.${sortTerm.field}`;
2011
- }
2012
- // If not found, default to groupId (already set)
2013
- }
2014
-
2015
- sortObject[sortFieldPath] = sortTerm.direction;
2016
- });
2017
-
2018
- // Add sort stage with all sort fields
2019
- aggregateClauses.push({
2020
- $sort: sortObject,
1660
+ sortTerms.forEach((sortTerm) => {
1661
+ const field = sortTerm.field || 'groupId';
1662
+ const sortFieldPath = factNames.includes(field) ? `facts.${field}` : 'groupId';
1663
+ sortObject[sortFieldPath] = sortTerm.order === 'ASC' ? 1 : -1;
2021
1664
  });
1665
+ aggregateClauses.push({ $sort: sortObject });
2022
1666
  } else {
2023
- // Default sort by groupId ascending if no sort terms provided
2024
- aggregateClauses.push({
2025
- $sort: { groupId: 1 },
2026
- });
2027
- }
2028
-
2029
- // Add pagination if provided
2030
- if (limitClause) {
2031
- aggregateClauses.push(limitClause);
1667
+ aggregateClauses.push({ $sort: { groupId: 1 } });
2032
1668
  }
2033
- if (skipClause) {
2034
- aggregateClauses.push(skipClause);
1669
+
1670
+ if (input.pagination?.page && input.pagination?.size) {
1671
+ const skip = input.pagination.size * (input.pagination.page - 1);
1672
+ aggregateClauses.push({ $skip: skip }, { $limit: input.pagination.size });
2035
1673
  }
2036
-
1674
+
2037
1675
  return aggregateClauses;
2038
1676
  };
2039
1677
 
@@ -2046,7 +1684,6 @@ const buildRootQuery = (name, includedTypes) => {
2046
1684
  if (!shouldNotBeIncludedInSchema(includedTypes, type.gqltype)) {
2047
1685
  const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
2048
1686
  if (!wasAddedAsNoEnpointType) {
2049
- // Fixing resolve method in order to be compliant with Mongo _id field
2050
1687
  if (type.gqltype.getFields().id && !type.gqltype.getFields().id.resolve) {
2051
1688
  type.gqltype.getFields().id.resolve = (parent) => parent._id;
2052
1689
  }
@@ -2055,54 +1692,26 @@ const buildRootQuery = (name, includedTypes) => {
2055
1692
  type: type.gqltype,
2056
1693
  args: { id: { type: GraphQLID } },
2057
1694
  async resolve(parent, args, context) {
2058
- /* Here we define how to get data from database source
2059
- this will return the type with id passed in argument
2060
- by the user */
2061
1695
  const params = {
2062
- type,
2063
- args,
2064
- operation: 'get_by_id',
2065
- context,
1696
+ type, args, operation: 'get_by_id', context,
2066
1697
  };
2067
- excecuteMiddleware(params);
2068
-
2069
- // Check if scope is defined for get_by_id
2070
- const hasScope = type.gqltype.extensions && type.gqltype.extensions.scope && type.gqltype.extensions.scope.get_by_id;
2071
-
2072
- if (hasScope) {
2073
- // Build query args with id filter - scope function will modify this
2074
- const queryArgs = {
2075
- id: { operator: 'EQ', value: args.id },
2076
- };
2077
-
2078
- // Create temporary params with queryArgs for scope function
2079
- const scopeParams = {
2080
- type,
2081
- args: queryArgs,
2082
- operation: 'get_by_id',
2083
- context,
2084
- };
2085
-
2086
- // Execute scope which will modify queryArgs in place
2087
- await executeScope(scopeParams);
2088
-
2089
- // Build aggregation pipeline from the combined filters
2090
- const aggregateClauses = await buildQuery(queryArgs, type.gqltype);
2091
-
2092
- // Execute the query and get the first result
2093
- let result;
2094
- if (aggregateClauses.length === 0) {
2095
- result = await type.model.findOne({ _id: args.id });
2096
- } else {
2097
- const results = await type.model.aggregate(aggregateClauses);
2098
- result = results.length > 0 ? results[0] : null;
2099
- }
2100
-
2101
- return result;
2102
- } else {
2103
- // No scope defined, use the original findById
2104
- return await type.model.findById(args.id);
1698
+ await executeMiddleware(params);
1699
+
1700
+ const hasScope = type.gqltype.extensions?.scope?.get_by_id;
1701
+ if (!hasScope) {
1702
+ return type.model.findById(args.id);
1703
+ }
1704
+
1705
+ const queryArgs = { id: { operator: 'EQ', value: args.id } };
1706
+ await executeScope({
1707
+ type, args: queryArgs, operation: 'get_by_id', context,
1708
+ });
1709
+ const aggregateClauses = await buildQuery(queryArgs, type.gqltype);
1710
+ if (aggregateClauses.length === 0) {
1711
+ return type.model.findOne({ _id: args.id });
2105
1712
  }
1713
+ const results = await type.model.aggregate(aggregateClauses);
1714
+ return results[0] || null;
2106
1715
  },
2107
1716
  };
2108
1717
 
@@ -2120,26 +1729,26 @@ const buildRootQuery = (name, includedTypes) => {
2120
1729
  operation: 'find',
2121
1730
  context,
2122
1731
  };
2123
- excecuteMiddleware(params);
1732
+ await executeMiddleware(params);
2124
1733
  await executeScope(params);
2125
1734
  const aggregateClauses = await buildQuery(args, type.gqltype);
2126
- if (args.pagination && args.pagination.count) {
2127
- const aggregateClausesForCount = await buildQuery(args, type.gqltype, true);
2128
- const resultCount = await type.model.aggregate(aggregateClausesForCount);
2129
- context.count = resultCount[0] ? resultCount[0].size : 0;
2130
- }
1735
+ const wantsCount = !!(args.pagination && args.pagination.count);
2131
1736
 
2132
- let result;
2133
- if (aggregateClauses.length === 0) {
2134
- result = await type.model.find({});
2135
- } else {
2136
- result = await type.model.aggregate(aggregateClauses);
1737
+ const dataPromise = aggregateClauses.length === 0
1738
+ ? type.model.find({})
1739
+ : type.model.aggregate(aggregateClauses);
1740
+ const countPromise = wantsCount
1741
+ ? buildQuery(args, type.gqltype, true).then((p) => type.model.aggregate(p))
1742
+ : null;
1743
+
1744
+ const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
1745
+ if (wantsCount) {
1746
+ context.count = resultCount[0] ? resultCount[0].size : 0;
2137
1747
  }
2138
1748
  return result;
2139
1749
  },
2140
1750
  };
2141
1751
 
2142
- // Add aggregate endpoint
2143
1752
  const aggregateArgsObject = { ...argsObject };
2144
1753
  aggregateArgsObject.aggregation = {
2145
1754
  type: new GraphQLNonNull(QLTypeAggregationExpression),
@@ -2155,7 +1764,7 @@ const buildRootQuery = (name, includedTypes) => {
2155
1764
  operation: 'aggregate',
2156
1765
  context,
2157
1766
  };
2158
- excecuteMiddleware(params);
1767
+ await executeMiddleware(params);
2159
1768
  await executeScope(params);
2160
1769
  const aggregateClauses = await buildAggregationQuery(args, type.gqltype, args.aggregation);
2161
1770
  const result = await type.model.aggregate(aggregateClauses);
@@ -2169,33 +1778,24 @@ const buildRootQuery = (name, includedTypes) => {
2169
1778
  return new GraphQLObjectType(rootQueryArgs);
2170
1779
  };
2171
1780
 
2172
- /* Creating a new GraphQL Schema, with options query which defines query
2173
- we will allow users to use when they are making request. */
2174
- export const createSchema = (includedQueryTypes,
2175
- includedMutationTypes, includedCustomMutations) => {
2176
-
2177
- // Generate models for all registered types now that all types are available
2178
- Object.values(typesDict.types).forEach(typeInfo => {
1781
+ export const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
1782
+ Object.values(typesDict.types).forEach((typeInfo) => {
2179
1783
  if (typeInfo.gqltype && !typeInfo.model) {
2180
1784
  if (typeInfo.endpoint) {
2181
- // Generate model with collection for endpoint types (types registered with connect)
2182
1785
  typeInfo.model = generateModel(typeInfo.gqltype, typeInfo.onModelCreated);
2183
1786
  } else if (typeInfo.needsModel) {
2184
- // Generate model without collection for no-endpoint types that need models (addNoEndpointType)
2185
- typeInfo.model = generateModelWithoutCollection(typeInfo.gqltype, null);
1787
+ typeInfo.model = generateModel(typeInfo.gqltype, null, { createCollection: false });
2186
1788
  }
2187
1789
  }
2188
1790
  });
2189
1791
 
2190
- // Also update the typesDictForUpdate with the generated models
2191
- Object.keys(typesDict.types).forEach(typeName => {
1792
+ Object.keys(typesDict.types).forEach((typeName) => {
2192
1793
  if (typesDictForUpdate.types[typeName]) {
2193
1794
  typesDictForUpdate.types[typeName].model = typesDict.types[typeName].model;
2194
1795
  }
2195
1796
  });
2196
1797
 
2197
- // Auto-generate resolvers for all registered types now that all types are available
2198
- Object.values(typesDict.types).forEach(typeInfo => {
1798
+ Object.values(typesDict.types).forEach((typeInfo) => {
2199
1799
  if (typeInfo.gqltype) {
2200
1800
  autoGenerateResolvers(typeInfo.gqltype);
2201
1801
  }
@@ -2213,7 +1813,6 @@ export const getType = (typeName) => {
2213
1813
  if (typeof typeName === 'string') {
2214
1814
  return typesDict.types[typeName]?.gqltype;
2215
1815
  }
2216
- // If it's already a GraphQL type object, get by its name
2217
1816
  if (typeName && typeName.name) {
2218
1817
  return typesDict.types[typeName.name]?.gqltype;
2219
1818
  }
@@ -2233,68 +1832,41 @@ const autoGenerateResolvers = (gqltype) => {
2233
1832
  const fields = gqltype.getFields();
2234
1833
 
2235
1834
  for (const [fieldName, fieldEntry] of Object.entries(fields)) {
2236
- // Skip if resolve method already exists
2237
- if (!fieldEntry.resolve) {
2238
- // Check if field has relation extension
2239
- if (fieldEntry.extensions && fieldEntry.extensions.relation) {
2240
- const { relation } = fieldEntry.extensions;
2241
-
2242
- // Only generate resolvers for non-embedded relationships
2243
- if (!relation.embedded) {
2244
- if (fieldEntry.type instanceof GraphQLList) {
2245
- // Collection field - generate resolve for one-to-many relationship
2246
- //This is a one-to-many resolver that will return a list of related objects. Also this one allows to filter the related objects as is in the find endpoint.
2247
- const relatedType = fieldEntry.type.ofType;
2248
- const connectionField = relation.connectionField || fieldName;
2249
- const relatedTypeInfo = typesDict.types[relatedType.name];
2250
- const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.getFields());
2251
-
2252
- delete argsObject[connectionField];
2253
- const argsArray = Object.entries(argsObject);
2254
-
2255
-
2256
- const graphqlArgs = formatArgs(argsArray);
2257
-
2258
- fieldEntry.args = graphqlArgs;
2259
-
2260
- fieldEntry.resolve = async (parent, args) => {
2261
- // Lazy lookup of the related model
2262
-
2263
- if (!relatedTypeInfo || !relatedTypeInfo.model) {
2264
- throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
2265
- }
2266
-
2267
- args[connectionField] = {
2268
- terms: [{
2269
- path: 'id',
2270
- operator: 'EQ',
2271
- value: parent.id || parent._id,
2272
- }],
2273
- };
2274
-
2275
-
2276
- const aggregateClauses = await buildQuery(args, relatedTypeInfo.gqltype);
2277
-
2278
- return await relatedTypeInfo.model.aggregate(aggregateClauses);
2279
- };
2280
- } else if (fieldEntry.type instanceof GraphQLObjectType
2281
- || (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
2282
- // Single object field - generate resolve for one-to-one relationship
2283
- const relatedType = fieldEntry.type instanceof GraphQLNonNull ? fieldEntry.type.ofType : fieldEntry.type;
2284
- const connectionField = relation.connectionField || fieldName;
2285
-
2286
- fieldEntry.resolve = async (parent) => {
2287
- // Lazy lookup of the related model
2288
- const relatedTypeInfo = typesDict.types[relatedType.name];
2289
- if (!relatedTypeInfo || !relatedTypeInfo.model) {
2290
- throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
2291
- }
2292
- const relatedId = parent[connectionField] || parent[fieldName];
2293
- return relatedId ? await relatedTypeInfo.model.findById(relatedId) : null;
2294
- };
2295
- }
1835
+ if (fieldEntry.resolve) continue;
1836
+ const relation = fieldEntry.extensions?.relation;
1837
+ if (!relation || relation.embedded) continue;
1838
+
1839
+ if (fieldEntry.type instanceof GraphQLList) {
1840
+ const relatedType = fieldEntry.type.ofType;
1841
+ const connectionField = relation.connectionField || fieldName;
1842
+ const relatedTypeInfo = typesDict.types[relatedType.name];
1843
+ const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.getFields());
1844
+ delete argsObject[connectionField];
1845
+
1846
+ fieldEntry.args = formatArgs(Object.entries(argsObject));
1847
+ fieldEntry.resolve = async (parent, args) => {
1848
+ if (!relatedTypeInfo || !relatedTypeInfo.model) {
1849
+ throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
2296
1850
  }
2297
- }
1851
+ args[connectionField] = {
1852
+ terms: [{ path: 'id', operator: 'EQ', value: parent.id || parent._id }],
1853
+ };
1854
+ const aggregateClauses = await buildQuery(args, relatedTypeInfo.gqltype);
1855
+ return relatedTypeInfo.model.aggregate(aggregateClauses);
1856
+ };
1857
+ } else if (fieldEntry.type instanceof GraphQLObjectType
1858
+ || (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
1859
+ const relatedType = unwrapNonNull(fieldEntry.type);
1860
+ const connectionField = relation.connectionField || fieldName;
1861
+
1862
+ fieldEntry.resolve = async (parent) => {
1863
+ const relatedTypeInfo = typesDict.types[relatedType.name];
1864
+ if (!relatedTypeInfo || !relatedTypeInfo.model) {
1865
+ throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
1866
+ }
1867
+ const relatedId = parent[connectionField] || parent[fieldName];
1868
+ return relatedId ? relatedTypeInfo.model.findById(relatedId) : null;
1869
+ };
2298
1870
  }
2299
1871
  }
2300
1872
  };
@@ -2306,43 +1878,36 @@ export const connect = (model, gqltype, simpleEntityEndpointName,
2306
1878
  gqltype,
2307
1879
  };
2308
1880
  typesDict.types[gqltype.name] = {
2309
- model: model, // Will be generated later in createSchema if not provided
1881
+ model,
2310
1882
  gqltype,
2311
1883
  simpleEntityEndpointName,
2312
1884
  listEntitiesEndpointName,
2313
1885
  endpoint: true,
2314
1886
  controller,
2315
1887
  stateMachine,
2316
- onModelCreated, // Store the callback for later use
1888
+ onModelCreated,
2317
1889
  };
2318
1890
 
2319
1891
  typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
2320
1892
  };
2321
1893
 
2322
1894
  export const addNoEndpointType = (gqltype) => {
2323
- waitingInputType[gqltype.name] = {
2324
- gqltype,
2325
- };
1895
+ waitingInputType[gqltype.name] = { gqltype };
2326
1896
 
2327
- // Check if this type has relationship fields that might need a model
2328
1897
  const fields = gqltype.getFields();
2329
1898
  let needsModel = false;
2330
-
2331
- for (const [, fieldEntry] of Object.entries(fields)) {
2332
- if (fieldEntry.extensions && fieldEntry.extensions.relation
2333
- && (fieldEntry.type instanceof GraphQLObjectType || fieldEntry.type instanceof GraphQLList
2334
- || (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType))) {
1899
+ for (const fieldEntry of Object.values(fields)) {
1900
+ if (fieldEntry.extensions?.relation
1901
+ && (fieldEntry.type instanceof GraphQLObjectType
1902
+ || fieldEntry.type instanceof GraphQLList
1903
+ || (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType))) {
2335
1904
  needsModel = true;
2336
1905
  break;
2337
1906
  }
2338
1907
  }
2339
1908
 
2340
1909
  typesDict.types[gqltype.name] = {
2341
- gqltype,
2342
- endpoint: false,
2343
- // Model will be generated later in createSchema if needed
2344
- model: null,
2345
- needsModel, // Store whether this type needs a model
1910
+ gqltype, endpoint: false, model: null, needsModel,
2346
1911
  };
2347
1912
 
2348
1913
  typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };