@strapi/database 4.4.0-rc.0 → 4.5.0-alpha.0

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.
@@ -12,19 +12,35 @@ const {
12
12
  isEmpty,
13
13
  isArray,
14
14
  isNull,
15
+ uniqBy,
16
+ differenceBy,
17
+ groupBy,
15
18
  } = require('lodash/fp');
16
19
  const types = require('../types');
17
20
  const { createField } = require('../fields');
18
21
  const { createQueryBuilder } = require('../query');
19
22
  const { createRepository } = require('./entity-repository');
20
- const { isBidirectional, isOneToAny } = require('../metadata/relations');
21
23
  const { deleteRelatedMorphOneRelationsAfterMorphToManyUpdate } = require('./morph-relations');
24
+ const {
25
+ isBidirectional,
26
+ isOneToAny,
27
+ isManyToAny,
28
+ isAnyToOne,
29
+ isAnyToMany,
30
+ } = require('../metadata/relations');
22
31
 
23
32
  const toId = (value) => value.id || value;
24
33
  const toIds = (value) => castArray(value || []).map(toId);
25
34
 
26
35
  const isValidId = (value) => isString(value) || isInteger(value);
27
36
  const toAssocs = (data) => {
37
+ if (data?.connect || data?.disconnect || (isPlainObject(data) && !data.id)) {
38
+ return {
39
+ connect: toAssocs(data.connect),
40
+ disconnect: toAssocs(data.disconnect),
41
+ };
42
+ }
43
+
28
44
  return castArray(data)
29
45
  .filter((datum) => !isNil(datum))
30
46
  .map((datum) => {
@@ -466,17 +482,56 @@ const createEntityManager = (db) => {
466
482
  // need to set the column on the target
467
483
 
468
484
  const { joinTable } = attribute;
469
- const { joinColumn, inverseJoinColumn } = joinTable;
485
+ const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } =
486
+ joinTable;
487
+ const select = [joinColumn.name];
488
+ if (isAnyToMany(attribute)) {
489
+ select.push(orderColumnName);
490
+ }
491
+
492
+ const cleanRelationData = toAssocs(data[attributeName]);
493
+ const relsToAdd = uniqBy('id', cleanRelationData.connect || cleanRelationData);
494
+ const relIdsToadd = toIds(relsToAdd);
495
+
496
+ // need to delete the previous relations for oneToAny relations
497
+ if (isBidirectional(attribute) && isOneToAny(attribute)) {
498
+ // update orders for previous oneToAny relations that will be deleted if it has order (oneToMany)
499
+ if (isAnyToMany(attribute)) {
500
+ const currentRelsToDelete = await this.createQueryBuilder(joinTable.name)
501
+ .select(select)
502
+ .where({
503
+ [inverseJoinColumn.name]: relIdsToadd,
504
+ [joinColumn.name]: { $ne: id },
505
+ })
506
+ .where(joinTable.on || {})
507
+ .execute();
470
508
 
471
- if (isOneToAny(attribute) && isBidirectional(attribute)) {
509
+ currentRelsToDelete.sort((a, b) => b[orderColumnName] - a[orderColumnName]);
510
+
511
+ for (const relToDelete of currentRelsToDelete) {
512
+ if (relToDelete[orderColumnName] !== null) {
513
+ await this.createQueryBuilder(joinTable.name)
514
+ .decrement(orderColumnName, 1)
515
+ .where({
516
+ [joinColumn.name]: relToDelete[joinColumn.name],
517
+ [orderColumnName]: { $gt: relToDelete[orderColumnName] },
518
+ })
519
+ .where(joinTable.on || {})
520
+ .execute();
521
+ }
522
+ }
523
+ }
524
+
525
+ // delete previous oneToAny relations
472
526
  await this.createQueryBuilder(joinTable.name)
473
527
  .delete()
474
- .where({ [inverseJoinColumn.name]: castArray(data[attributeName]) })
528
+ .where({ [inverseJoinColumn.name]: relIdsToadd })
475
529
  .where(joinTable.on || {})
476
530
  .execute();
477
531
  }
478
532
 
479
- const insert = toAssocs(data[attributeName]).map((data) => {
533
+ // prepare new relations to insert
534
+ const insert = relsToAdd.map((data) => {
480
535
  return {
481
536
  [joinColumn.name]: id,
482
537
  [inverseJoinColumn.name]: data.id,
@@ -485,11 +540,38 @@ const createEntityManager = (db) => {
485
540
  };
486
541
  });
487
542
 
488
- // if there is nothing to insert
543
+ // add order value when relevant
544
+ if (isAnyToMany(attribute)) {
545
+ insert.forEach((rel, idx) => {
546
+ rel[orderColumnName] = idx + 1;
547
+ });
548
+ }
549
+ // add inv_order value when relevant
550
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
551
+ const maxMap = {};
552
+ await Promise.all(
553
+ relIdsToadd.map(async (relId) => {
554
+ const { max } = await this.createQueryBuilder(joinTable.name)
555
+ .max(inverseOrderColumnName)
556
+ .where({ [inverseJoinColumn.name]: relId })
557
+ .where(joinTable.on || {})
558
+ .first()
559
+ .execute();
560
+
561
+ maxMap[relId] = max;
562
+ })
563
+ );
564
+
565
+ insert.forEach((rel) => {
566
+ rel[inverseOrderColumnName] = maxMap[rel[inverseJoinColumn.name]] + 1;
567
+ });
568
+ }
569
+
489
570
  if (insert.length === 0) {
490
571
  continue;
491
572
  }
492
573
 
574
+ // insert new relations
493
575
  await this.createQueryBuilder(joinTable.name).insert(insert).execute();
494
576
  }
495
577
  }
@@ -644,42 +726,434 @@ const createEntityManager = (db) => {
644
726
 
645
727
  if (attribute.joinTable) {
646
728
  const { joinTable } = attribute;
647
- const { joinColumn, inverseJoinColumn } = joinTable;
729
+ const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } =
730
+ joinTable;
731
+ const select = [joinColumn.name, inverseJoinColumn.name];
732
+ if (isAnyToMany(attribute)) {
733
+ select.push(orderColumnName);
734
+ }
735
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
736
+ select.push(inverseOrderColumnName);
737
+ }
648
738
 
649
- // clear previous associations in the joinTable
650
- await this.createQueryBuilder(joinTable.name)
651
- .delete()
652
- .where({ [joinColumn.name]: id })
653
- .where(joinTable.on || {})
654
- .execute();
739
+ // only delete relations
740
+ if (isNull(data[attributeName])) {
741
+ // INVERSE ORDER UPDATE
742
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
743
+ let lastId = 0;
744
+ let done = false;
745
+ const batchSize = 100;
746
+ while (!done) {
747
+ const relsToDelete = await this.createQueryBuilder(joinTable.name)
748
+ .select(select)
749
+ .where({
750
+ [joinColumn.name]: id,
751
+ id: { $gt: lastId },
752
+ })
753
+ .where(joinTable.on || {})
754
+ .orderBy('id')
755
+ .limit(batchSize)
756
+ .execute();
757
+ // TODO: cannot put pivot here...
758
+ done = relsToDelete.length < batchSize;
759
+ lastId = relsToDelete[relsToDelete.length - 1]?.id;
760
+
761
+ const updateInverseOrderPromises = [];
762
+ for (const relToDelete of relsToDelete) {
763
+ if (relToDelete[inverseOrderColumnName] !== null) {
764
+ const updatePromise = this.createQueryBuilder(joinTable.name)
765
+ .decrement(inverseOrderColumnName, 1)
766
+ .where({
767
+ [inverseJoinColumn.name]: relToDelete[inverseJoinColumn.name],
768
+ [inverseOrderColumnName]: { $gt: relToDelete[inverseOrderColumnName] },
769
+ })
770
+ .where(joinTable.on || {})
771
+ .execute();
772
+ updateInverseOrderPromises.push(updatePromise);
773
+ }
774
+ }
775
+
776
+ await Promise.all(updateInverseOrderPromises);
777
+ }
778
+ }
655
779
 
656
- if (
657
- isBidirectional(attribute) &&
658
- ['oneToOne', 'oneToMany'].includes(attribute.relation)
659
- ) {
660
780
  await this.createQueryBuilder(joinTable.name)
661
781
  .delete()
662
- .where({ [inverseJoinColumn.name]: toIds(data[attributeName]) })
782
+ .where({ [joinColumn.name]: id })
663
783
  .where(joinTable.on || {})
664
784
  .execute();
665
- }
785
+ } else {
786
+ const cleanRelationData = toAssocs(data[attributeName]);
787
+ const isPartialUpdate =
788
+ has('connect', cleanRelationData) || has('disconnect', cleanRelationData);
789
+ let relIdsToaddOrMove;
790
+
791
+ if (isPartialUpdate) {
792
+ // does not support pivot
793
+ let connect = uniqBy('id', cleanRelationData.connect || []);
794
+ let disconnect = uniqBy('id', cleanRelationData.disconnect || []);
795
+ if (isAnyToOne(attribute)) {
796
+ connect = connect.slice(-1);
797
+ disconnect = [];
798
+ }
799
+ relIdsToaddOrMove = toIds(connect);
800
+ // DELETE relations in disconnect
801
+ const relIdsToDelete = toIds(differenceBy('id', disconnect, connect));
802
+
803
+ // UPDATE RELEVANT ORDERS
804
+ if (
805
+ isAnyToMany(attribute) ||
806
+ (isBidirectional(attribute) && isManyToAny(attribute))
807
+ ) {
808
+ const relsToDelete = await this.createQueryBuilder(joinTable.name)
809
+ .select(select)
810
+ .where({
811
+ [joinColumn.name]: id,
812
+ [inverseJoinColumn.name]: { $in: relIdsToDelete },
813
+ })
814
+ .where(joinTable.on || {})
815
+ .execute();
816
+
817
+ // ORDER UPDATE
818
+ if (isAnyToMany(attribute)) {
819
+ // sort by order DESC so that the order updates are done in the correct order
820
+ // avoiding one to interfere with the others
821
+ relsToDelete.sort((a, b) => b[orderColumnName] - a[orderColumnName]);
822
+
823
+ for (const relToDelete of relsToDelete) {
824
+ if (relToDelete[orderColumnName] !== null) {
825
+ await this.createQueryBuilder(joinTable.name)
826
+ .decrement(orderColumnName, 1)
827
+ .where({
828
+ [joinColumn.name]: id,
829
+ [orderColumnName]: { $gt: relToDelete[orderColumnName] },
830
+ })
831
+ .where(joinTable.on || {})
832
+ .execute();
833
+ }
834
+ }
835
+ }
836
+
837
+ // INVERSE ORDER UPDATE
838
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
839
+ const updateInverseOrderPromises = [];
840
+ for (const relToDelete of relsToDelete) {
841
+ if (relToDelete[inverseOrderColumnName] !== null) {
842
+ const updatePromise = this.createQueryBuilder(joinTable.name)
843
+ .decrement(inverseOrderColumnName, 1)
844
+ .where({
845
+ [inverseJoinColumn.name]: relToDelete[inverseJoinColumn.name],
846
+ [inverseOrderColumnName]: { $gt: relToDelete[inverseOrderColumnName] },
847
+ })
848
+ .where(joinTable.on || {})
849
+ .execute();
850
+ updateInverseOrderPromises.push(updatePromise);
851
+ }
852
+ }
853
+
854
+ await Promise.all(updateInverseOrderPromises);
855
+ }
856
+ }
857
+
858
+ // DELETE
859
+ await this.createQueryBuilder(joinTable.name)
860
+ .delete()
861
+ .where({
862
+ [joinColumn.name]: id,
863
+ [inverseJoinColumn.name]: { $in: relIdsToDelete },
864
+ })
865
+ .where(joinTable.on || {})
866
+ .execute();
666
867
 
667
- if (!isNull(data[attributeName])) {
668
- const insert = toAssocs(data[attributeName]).map((data) => {
669
- return {
670
- [joinColumn.name]: id,
671
- [inverseJoinColumn.name]: data.id,
672
- ...(joinTable.on || {}),
673
- ...(data.__pivot || {}),
674
- };
675
- });
868
+ // add/move
869
+ let max;
870
+ const currentMovingRels = await this.createQueryBuilder(joinTable.name)
871
+ .select(select)
872
+ .where({
873
+ [joinColumn.name]: id,
874
+ [inverseJoinColumn.name]: { $in: relIdsToaddOrMove },
875
+ })
876
+ .where(joinTable.on || {})
877
+ .execute();
878
+ const currentMovingRelsMap = groupBy(inverseJoinColumn.name, currentMovingRels);
879
+
880
+ if (isAnyToMany(attribute)) {
881
+ max = (
882
+ await this.createQueryBuilder(joinTable.name)
883
+ .max(orderColumnName)
884
+ .where({ [joinColumn.name]: id })
885
+ .where(joinTable.on || {})
886
+ .first()
887
+ .execute()
888
+ ).max;
889
+ }
890
+
891
+ for (const relToAddOrMove of connect) {
892
+ // const currentRel = currentMovingRelsMap[relToAddOrMove.id]?.[0];
893
+ const currentRel = currentMovingRelsMap[relToAddOrMove.id]?.[0];
894
+ if (currentRel && isAnyToMany(attribute)) {
895
+ const currentOrderIsNull = currentRel[orderColumnName] === null;
896
+ if (!currentOrderIsNull) {
897
+ await this.createQueryBuilder(joinTable.name)
898
+ .decrement(orderColumnName, 1)
899
+ .where({
900
+ [joinColumn.name]: id,
901
+ [orderColumnName]: { $gt: currentRel[orderColumnName] },
902
+ })
903
+ .where(joinTable.on || {})
904
+ .execute();
905
+
906
+ currentMovingRels.forEach((rel) => {
907
+ if (rel[orderColumnName] > currentRel[orderColumnName]) {
908
+ rel[orderColumnName] -= 1;
909
+ }
910
+ });
911
+ }
912
+
913
+ await this.createQueryBuilder(joinTable.name)
914
+ .update({
915
+ [orderColumnName]: currentOrderIsNull ? max + 1 : max,
916
+ })
917
+ .where({
918
+ [joinColumn.name]: id,
919
+ [inverseJoinColumn.name]: relToAddOrMove.id,
920
+ })
921
+ .where(joinTable.on || {})
922
+ .execute();
923
+ } else if (!currentRel) {
924
+ const insert = {
925
+ [joinColumn.name]: id,
926
+ [inverseJoinColumn.name]: relToAddOrMove.id,
927
+ ...(relToAddOrMove.__pivot || {}),
928
+ };
929
+
930
+ if (isAnyToMany(attribute)) {
931
+ insert[orderColumnName] = max + 1;
932
+ }
933
+
934
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
935
+ const { max: reverseMax } = await this.createQueryBuilder(joinTable.name)
936
+ .max(inverseOrderColumnName)
937
+ .where({ [inverseJoinColumn.name]: relToAddOrMove.id })
938
+ .where(joinTable.on || {})
939
+ .first()
940
+ .execute();
941
+
942
+ insert[inverseOrderColumnName] = reverseMax + 1;
943
+ }
944
+
945
+ await this.createQueryBuilder(joinTable.name).insert(insert).execute();
946
+ max += 1;
947
+ }
948
+ }
949
+ } else {
950
+ // overwrite all relations
951
+ const relsToAdd = uniqBy('id', cleanRelationData);
952
+ relIdsToaddOrMove = toIds(relsToAdd);
953
+
954
+ // UPDATE RELEVANT ORDERS BEFORE DELETE
955
+ if (isAnyToMany(attribute) || isManyToAny(attribute)) {
956
+ let lastId = 0;
957
+ let done = false;
958
+ const batchSize = 100;
959
+ while (!done) {
960
+ const relsToDelete = await this.createQueryBuilder(joinTable.name)
961
+ .select(select)
962
+ .where({
963
+ [joinColumn.name]: id,
964
+ [inverseJoinColumn.name]: { $notIn: relIdsToaddOrMove },
965
+ id: { $gt: lastId },
966
+ })
967
+ .where(joinTable.on || {})
968
+ .orderBy('id')
969
+ .limit(batchSize)
970
+ .execute();
971
+
972
+ done = relsToDelete.length < batchSize;
973
+ lastId = relsToDelete[relsToDelete.length - 1]?.id;
974
+
975
+ // ORDER UPDATE
976
+ if (isAnyToMany(attribute)) {
977
+ // sort by order DESC so that the order updates are done in the correct order
978
+ // avoiding one to interfere with the others
979
+ relsToDelete.sort((a, b) => b[orderColumnName] - a[orderColumnName]);
980
+
981
+ for (const relToDelete of relsToDelete) {
982
+ if (relToDelete[orderColumnName] !== null) {
983
+ await this.createQueryBuilder(joinTable.name)
984
+ .decrement(orderColumnName, 1)
985
+ .where({
986
+ [joinColumn.name]: id,
987
+ [orderColumnName]: { $gt: relToDelete[orderColumnName] },
988
+ })
989
+ .where(joinTable.on || {})
990
+ // manque le pivot ici
991
+ .execute();
992
+ }
993
+ }
994
+ }
995
+
996
+ // INVERSE ORDER UPDATE
997
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
998
+ const updateInverseOrderPromises = [];
999
+ for (const relToDelete of relsToDelete) {
1000
+ if (relToDelete[inverseOrderColumnName] !== null) {
1001
+ const updatePromise = this.createQueryBuilder(joinTable.name)
1002
+ .decrement(inverseOrderColumnName, 1)
1003
+ .where({
1004
+ [inverseJoinColumn.name]: relToDelete[inverseJoinColumn.name],
1005
+ [inverseOrderColumnName]: { $gt: relToDelete[inverseOrderColumnName] },
1006
+ })
1007
+ .where(joinTable.on || {})
1008
+ .execute();
1009
+ updateInverseOrderPromises.push(updatePromise);
1010
+ }
1011
+ }
1012
+
1013
+ await Promise.all(updateInverseOrderPromises);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ // DELETE
1019
+ await this.createQueryBuilder(joinTable.name)
1020
+ .delete()
1021
+ .where({
1022
+ [joinColumn.name]: id,
1023
+ [inverseJoinColumn.name]: { $notIn: relIdsToaddOrMove },
1024
+ })
1025
+ .where(joinTable.on || {})
1026
+ .execute();
676
1027
 
677
- // if there is nothing to insert
678
- if (insert.length === 0) {
679
- continue;
1028
+ const currentMovingRels = await this.createQueryBuilder(joinTable.name)
1029
+ .select(select)
1030
+ .where({
1031
+ [joinColumn.name]: id,
1032
+ [inverseJoinColumn.name]: { $in: relIdsToaddOrMove },
1033
+ })
1034
+ .where(joinTable.on || {})
1035
+ .execute();
1036
+ const currentMovingRelsMap = groupBy(inverseJoinColumn.name, currentMovingRels);
1037
+
1038
+ let max = 0;
1039
+ for (const relToAdd of relsToAdd) {
1040
+ const currentRel = currentMovingRelsMap[relToAdd.id]?.[0];
1041
+
1042
+ if (currentRel && isAnyToMany(attribute)) {
1043
+ const update = { [orderColumnName]: max + 1 };
1044
+ await this.createQueryBuilder(joinTable.name)
1045
+ .update(update)
1046
+ .where({
1047
+ [joinColumn.name]: id,
1048
+ [inverseJoinColumn.name]: relToAdd.id,
1049
+ })
1050
+ .where(joinTable.on || {})
1051
+ .execute();
1052
+ } else if (!currentRel) {
1053
+ const insert = {
1054
+ [joinColumn.name]: id,
1055
+ [inverseJoinColumn.name]: relToAdd.id,
1056
+ ...(relToAdd.__pivot || {}),
1057
+ };
1058
+
1059
+ if (isAnyToMany(attribute)) {
1060
+ insert[orderColumnName] = max + 1;
1061
+ }
1062
+ // can be optimized in one query
1063
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
1064
+ const { max: reverseMax } = await this.createQueryBuilder(joinTable.name)
1065
+ .max(inverseOrderColumnName)
1066
+ .where({ [inverseJoinColumn.name]: id })
1067
+ .where(joinTable.on || {})
1068
+ .first()
1069
+ .execute();
1070
+
1071
+ insert[inverseOrderColumnName] = reverseMax + 1;
1072
+ }
1073
+
1074
+ await this.createQueryBuilder(joinTable.name).insert(insert).execute();
1075
+ }
1076
+ max += 1;
1077
+ }
1078
+ }
1079
+
1080
+ // Delete the previous relations for oneToAny relations
1081
+ if (isBidirectional(attribute) && isOneToAny(attribute)) {
1082
+ // update orders for previous oneToAny relations that will be deleted if it has order (oneToMany)
1083
+ if (isAnyToMany(attribute)) {
1084
+ const currentRelsToDelete = await this.createQueryBuilder(joinTable.name)
1085
+ .select(select)
1086
+ .where({
1087
+ [inverseJoinColumn.name]: relIdsToaddOrMove,
1088
+ [joinColumn.name]: { $ne: id },
1089
+ })
1090
+ .where(joinTable.on || {})
1091
+ .execute();
1092
+
1093
+ currentRelsToDelete.sort((a, b) => b[orderColumnName] - a[orderColumnName]);
1094
+
1095
+ for (const relToDelete of currentRelsToDelete) {
1096
+ if (relToDelete[orderColumnName] !== null) {
1097
+ await this.createQueryBuilder(joinTable.name)
1098
+ .decrement(orderColumnName, 1)
1099
+ .where({
1100
+ [joinColumn.name]: relToDelete[joinColumn.name],
1101
+ [orderColumnName]: { $gt: relToDelete[orderColumnName] },
1102
+ })
1103
+ .where(joinTable.on || {})
1104
+ .execute();
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ // delete previous oneToAny relations
1110
+ await this.createQueryBuilder(joinTable.name)
1111
+ .delete()
1112
+ .where({
1113
+ [inverseJoinColumn.name]: relIdsToaddOrMove,
1114
+ [joinColumn.name]: { $ne: id },
1115
+ })
1116
+ .where(joinTable.on || {})
1117
+ .execute();
680
1118
  }
681
1119
 
682
- await this.createQueryBuilder(joinTable.name).insert(insert).execute();
1120
+ // Delete the previous relations for anyToOne relations
1121
+ if (isBidirectional(attribute) && isAnyToOne(attribute)) {
1122
+ // update orders for previous anyToOne relations that will be deleted if it has order (manyToOne)
1123
+ if (isManyToAny(attribute)) {
1124
+ const currentRelsToDelete = await this.createQueryBuilder(joinTable.name)
1125
+ .select(select)
1126
+ .where({
1127
+ [joinColumn.name]: id,
1128
+ [inverseJoinColumn.name]: { $notIn: relIdsToaddOrMove },
1129
+ })
1130
+ .where(joinTable.on || {})
1131
+ .execute();
1132
+
1133
+ for (const relToDelete of currentRelsToDelete) {
1134
+ if (relToDelete[inverseOrderColumnName] !== null) {
1135
+ await this.createQueryBuilder(joinTable.name)
1136
+ .decrement(inverseOrderColumnName, 1)
1137
+ .where({
1138
+ [inverseJoinColumn.name]: relToDelete[inverseJoinColumn.name],
1139
+ [inverseOrderColumnName]: { $gt: relToDelete[inverseOrderColumnName] },
1140
+ })
1141
+ .where(joinTable.on || {})
1142
+ .execute();
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ // delete previous oneToAny relations
1148
+ await this.createQueryBuilder(joinTable.name)
1149
+ .delete()
1150
+ .where({
1151
+ [joinColumn.name]: id,
1152
+ [inverseJoinColumn.name]: { $notIn: relIdsToaddOrMove },
1153
+ })
1154
+ .where(joinTable.on || {})
1155
+ .execute();
1156
+ }
683
1157
  }
684
1158
  }
685
1159
  }
@@ -796,7 +1270,45 @@ const createEntityManager = (db) => {
796
1270
 
797
1271
  if (attribute.joinTable) {
798
1272
  const { joinTable } = attribute;
799
- const { joinColumn } = joinTable;
1273
+ const { joinColumn, inverseJoinColumn, inverseOrderColumnName } = joinTable;
1274
+
1275
+ // INVERSE ORDER UPDATE
1276
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
1277
+ let lastId = 0;
1278
+ let done = false;
1279
+ const batchSize = 100;
1280
+ while (!done) {
1281
+ const relsToDelete = await this.createQueryBuilder(joinTable.name)
1282
+ .select(inverseJoinColumn.name, inverseOrderColumnName)
1283
+ .where({
1284
+ [joinColumn.name]: id,
1285
+ id: { $gt: lastId },
1286
+ })
1287
+ .where(joinTable.on || {})
1288
+ .orderBy('id')
1289
+ .limit(batchSize)
1290
+ .execute();
1291
+ done = relsToDelete.length < batchSize;
1292
+ lastId = relsToDelete[relsToDelete.length - 1]?.id;
1293
+
1294
+ const updateInverseOrderPromises = [];
1295
+ for (const relToDelete of relsToDelete) {
1296
+ if (relToDelete[inverseOrderColumnName] !== null) {
1297
+ const updatePromise = this.createQueryBuilder(joinTable.name)
1298
+ .decrement(inverseOrderColumnName, 1)
1299
+ .where({
1300
+ [inverseJoinColumn.name]: relToDelete[inverseJoinColumn.name],
1301
+ [inverseOrderColumnName]: { $gt: relToDelete[inverseOrderColumnName] },
1302
+ })
1303
+ .where(joinTable.on || {})
1304
+ .execute();
1305
+ updateInverseOrderPromises.push(updatePromise);
1306
+ }
1307
+ }
1308
+
1309
+ await Promise.all(updateInverseOrderPromises);
1310
+ }
1311
+ }
800
1312
 
801
1313
  await this.createQueryBuilder(joinTable.name)
802
1314
  .delete()
@@ -208,6 +208,7 @@ const createComponent = (attributeName, attribute, meta) => {
208
208
  orderBy: {
209
209
  order: 'asc',
210
210
  },
211
+ ...(attribute.repeatable === true ? { orderColumnName: 'order' } : {}),
211
212
  },
212
213
  });
213
214
  };
@@ -10,6 +10,9 @@ const hasInversedBy = _.has('inversedBy');
10
10
  const hasMappedBy = _.has('mappedBy');
11
11
 
12
12
  const isOneToAny = (attribute) => ['oneToOne', 'oneToMany'].includes(attribute.relation);
13
+ const isManyToAny = (attribute) => ['manyToMany', 'manyToOne'].includes(attribute.relation);
14
+ const isAnyToOne = (attribute) => ['oneToOne', 'manyToOne'].includes(attribute.relation);
15
+ const isAnyToMany = (attribute) => ['oneToMany', 'manyToMany'].includes(attribute.relation);
13
16
  const isBidirectional = (attribute) => hasInversedBy(attribute) || hasMappedBy(attribute);
14
17
  const isOwner = (attribute) => !isBidirectional(attribute) || hasInversedBy(attribute);
15
18
  const shouldUseJoinTable = (attribute) => attribute.useJoinTable !== false;
@@ -398,12 +401,20 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => {
398
401
  const joinColumnName = _.snakeCase(`${meta.singularName}_id`);
399
402
  let inverseJoinColumnName = _.snakeCase(`${targetMeta.singularName}_id`);
400
403
 
401
- // if relation is slef referencing
404
+ // if relation is self referencing
402
405
  if (joinColumnName === inverseJoinColumnName) {
403
406
  inverseJoinColumnName = `inv_${inverseJoinColumnName}`;
404
407
  }
405
408
 
406
- metadata.add({
409
+ const orderColumnName = _.snakeCase(`${meta.singularName}_order`);
410
+ let inverseOrderColumnName = _.snakeCase(`${targetMeta.singularName}_order`);
411
+
412
+ // if relation is self referencing
413
+ if (attribute.relation === 'manyToMany' && joinColumnName === inverseJoinColumnName) {
414
+ inverseOrderColumnName = `inv_${inverseOrderColumnName}`;
415
+ }
416
+
417
+ const metadataSchema = {
407
418
  uid: joinTableName,
408
419
  tableName: joinTableName,
409
420
  attributes: {
@@ -450,7 +461,7 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => {
450
461
  onDelete: 'CASCADE',
451
462
  },
452
463
  ],
453
- });
464
+ };
454
465
 
455
466
  const joinTable = {
456
467
  name: joinTableName,
@@ -464,6 +475,43 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => {
464
475
  },
465
476
  };
466
477
 
478
+ // order
479
+ if (isAnyToMany(attribute)) {
480
+ metadataSchema.attributes[orderColumnName] = {
481
+ type: 'integer',
482
+ column: {
483
+ unsigned: true,
484
+ defaultTo: null,
485
+ },
486
+ };
487
+ metadataSchema.indexes.push({
488
+ name: `${joinTableName}_order_fk`,
489
+ columns: [orderColumnName],
490
+ });
491
+ joinTable.orderColumnName = orderColumnName;
492
+ joinTable.orderBy = { [orderColumnName]: 'asc' };
493
+ }
494
+
495
+ // inv order
496
+ if (isBidirectional(attribute) && isManyToAny(attribute)) {
497
+ metadataSchema.attributes[inverseOrderColumnName] = {
498
+ type: 'integer',
499
+ column: {
500
+ unsigned: true,
501
+ defaultTo: null,
502
+ },
503
+ };
504
+
505
+ metadataSchema.indexes.push({
506
+ name: `${joinTableName}_order_inv_fk`,
507
+ columns: [inverseOrderColumnName],
508
+ });
509
+
510
+ joinTable.inverseOrderColumnName = inverseOrderColumnName;
511
+ }
512
+
513
+ metadata.add(metadataSchema);
514
+
467
515
  attribute.joinTable = joinTable;
468
516
 
469
517
  if (isBidirectional(attribute)) {
@@ -480,6 +528,14 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => {
480
528
  joinColumn: joinTable.inverseJoinColumn,
481
529
  inverseJoinColumn: joinTable.joinColumn,
482
530
  };
531
+
532
+ if (isManyToAny(attribute)) {
533
+ inverseAttribute.joinTable.orderColumnName = inverseOrderColumnName;
534
+ inverseAttribute.joinTable.orderBy = { [inverseOrderColumnName]: 'asc' };
535
+ }
536
+ if (isAnyToMany(attribute)) {
537
+ inverseAttribute.joinTable.inverseOrderColumnName = orderColumnName;
538
+ }
483
539
  }
484
540
  };
485
541
 
@@ -488,4 +544,7 @@ module.exports = {
488
544
 
489
545
  isBidirectional,
490
546
  isOneToAny,
547
+ isManyToAny,
548
+ isAnyToOne,
549
+ isAnyToMany,
491
550
  };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const createPivotJoin = (qb, joinTable, alias, tragetMeta) => {
3
+ const createPivotJoin = (ctx, { alias, refAlias, joinTable, targetMeta }) => {
4
+ const { qb } = ctx;
4
5
  const joinAlias = qb.getAlias();
5
6
  qb.join({
6
7
  alias: joinAlias,
@@ -11,10 +12,10 @@ const createPivotJoin = (qb, joinTable, alias, tragetMeta) => {
11
12
  on: joinTable.on,
12
13
  });
13
14
 
14
- const subAlias = qb.getAlias();
15
+ const subAlias = refAlias || qb.getAlias();
15
16
  qb.join({
16
17
  alias: subAlias,
17
- referencedTable: tragetMeta.tableName,
18
+ referencedTable: targetMeta.tableName,
18
19
  referencedColumn: joinTable.inverseJoinColumn.referencedColumn,
19
20
  rootColumn: joinTable.inverseJoinColumn.name,
20
21
  rootTable: joinAlias,
@@ -23,22 +24,22 @@ const createPivotJoin = (qb, joinTable, alias, tragetMeta) => {
23
24
  return subAlias;
24
25
  };
25
26
 
26
- const createJoin = (ctx, { alias, attributeName, attribute }) => {
27
+ const createJoin = (ctx, { alias, refAlias, attributeName, attribute }) => {
27
28
  const { db, qb } = ctx;
28
29
 
29
30
  if (attribute.type !== 'relation') {
30
31
  throw new Error(`Cannot join on non relational field ${attributeName}`);
31
32
  }
32
33
 
33
- const tragetMeta = db.metadata.get(attribute.target);
34
+ const targetMeta = db.metadata.get(attribute.target);
34
35
 
35
36
  const { joinColumn } = attribute;
36
37
 
37
38
  if (joinColumn) {
38
- const subAlias = qb.getAlias();
39
+ const subAlias = refAlias || qb.getAlias();
39
40
  qb.join({
40
41
  alias: subAlias,
41
- referencedTable: tragetMeta.tableName,
42
+ referencedTable: targetMeta.tableName,
42
43
  referencedColumn: joinColumn.referencedColumn,
43
44
  rootColumn: joinColumn.name,
44
45
  rootTable: alias,
@@ -48,7 +49,7 @@ const createJoin = (ctx, { alias, attributeName, attribute }) => {
48
49
 
49
50
  const { joinTable } = attribute;
50
51
  if (joinTable) {
51
- return createPivotJoin(qb, joinTable, alias, tragetMeta);
52
+ return createPivotJoin(ctx, { alias, refAlias, joinTable, targetMeta });
52
53
  }
53
54
 
54
55
  return alias;
@@ -6,6 +6,7 @@ const types = require('../../types');
6
6
  const { createField } = require('../../fields');
7
7
  const { createJoin } = require('./join');
8
8
  const { toColumnName } = require('./transform');
9
+ const { isKnexQuery } = require('../../utils/knex');
9
10
 
10
11
  const GROUP_OPERATORS = ['$and', '$or'];
11
12
  const OPERATORS = [
@@ -206,12 +207,12 @@ const applyOperator = (qb, column, operator, value) => {
206
207
  }
207
208
 
208
209
  case '$in': {
209
- qb.whereIn(column, _.castArray(value));
210
+ qb.whereIn(column, isKnexQuery(value) ? value : _.castArray(value));
210
211
  break;
211
212
  }
212
213
 
213
214
  case '$notIn': {
214
- qb.whereNotIn(column, _.castArray(value));
215
+ qb.whereNotIn(column, isKnexQuery(value) ? value : _.castArray(value));
215
216
  break;
216
217
  }
217
218
 
@@ -4,33 +4,38 @@ const _ = require('lodash/fp');
4
4
 
5
5
  const helpers = require('./helpers');
6
6
 
7
- const createQueryBuilder = (uid, db) => {
7
+ const createQueryBuilder = (uid, db, initialState = {}) => {
8
8
  const meta = db.metadata.get(uid);
9
9
  const { tableName } = meta;
10
10
 
11
- const state = {
12
- type: 'select',
13
- select: [],
14
- count: null,
15
- max: null,
16
- first: false,
17
- data: null,
18
- where: [],
19
- joins: [],
20
- populate: null,
21
- limit: null,
22
- offset: null,
23
- transaction: null,
24
- forUpdate: false,
25
- orderBy: [],
26
- groupBy: [],
27
- };
11
+ const state = _.defaults(
12
+ {
13
+ type: 'select',
14
+ select: [],
15
+ count: null,
16
+ max: null,
17
+ first: false,
18
+ data: null,
19
+ where: [],
20
+ joins: [],
21
+ populate: null,
22
+ limit: null,
23
+ offset: null,
24
+ transaction: null,
25
+ forUpdate: false,
26
+ orderBy: [],
27
+ groupBy: [],
28
+ increments: [],
29
+ decrements: [],
30
+ aliasCounter: 0,
31
+ },
32
+ initialState
33
+ );
28
34
 
29
- let counter = 0;
30
35
  const getAlias = () => {
31
- const alias = `t${counter}`;
36
+ const alias = `t${state.aliasCounter}`;
32
37
 
33
- counter += 1;
38
+ state.aliasCounter += 1;
34
39
 
35
40
  return alias;
36
41
  };
@@ -40,6 +45,10 @@ const createQueryBuilder = (uid, db) => {
40
45
  getAlias,
41
46
  state,
42
47
 
48
+ clone() {
49
+ return createQueryBuilder(uid, db, state);
50
+ },
51
+
43
52
  select(args) {
44
53
  state.type = 'select';
45
54
  state.select = _.uniq(_.castArray(args));
@@ -77,6 +86,20 @@ const createQueryBuilder = (uid, db) => {
77
86
  return this;
78
87
  },
79
88
 
89
+ increment(column, amount = 1) {
90
+ state.type = 'update';
91
+ state.increments.push({ column, amount });
92
+
93
+ return this;
94
+ },
95
+
96
+ decrement(column, amount = 1) {
97
+ state.type = 'update';
98
+ state.decrements.push({ column, amount });
99
+
100
+ return this;
101
+ },
102
+
80
103
  count(count = 'id') {
81
104
  state.type = 'count';
82
105
  state.count = count;
@@ -195,7 +218,24 @@ const createQueryBuilder = (uid, db) => {
195
218
  },
196
219
 
197
220
  join(join) {
198
- state.joins.push(join);
221
+ if (!join.targetField) {
222
+ state.joins.push(join);
223
+ return this;
224
+ }
225
+
226
+ const model = db.metadata.get(uid);
227
+ const attribute = model.attributes[join.targetField];
228
+
229
+ helpers.createJoin(
230
+ { db, qb: this },
231
+ {
232
+ alias: this.alias,
233
+ refAlias: join.alias,
234
+ attributeName: join.targetField,
235
+ attribute,
236
+ }
237
+ );
238
+
199
239
  return this;
200
240
  },
201
241
 
@@ -325,7 +365,9 @@ const createQueryBuilder = (uid, db) => {
325
365
  break;
326
366
  }
327
367
  case 'update': {
328
- qb.update(state.data);
368
+ if (state.data) {
369
+ qb.update(state.data);
370
+ }
329
371
  break;
330
372
  }
331
373
  case 'delete': {
@@ -350,6 +392,14 @@ const createQueryBuilder = (uid, db) => {
350
392
  qb.forUpdate();
351
393
  }
352
394
 
395
+ if (!_.isEmpty(state.increments)) {
396
+ state.increments.forEach((incr) => qb.increment(incr.column, incr.amount));
397
+ }
398
+
399
+ if (!_.isEmpty(state.decrements)) {
400
+ state.decrements.forEach((decr) => qb.decrement(decr.column, decr.amount));
401
+ }
402
+
353
403
  if (state.limit) {
354
404
  qb.limit(state.limit);
355
405
  }
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const { createStrapiInstance } = require('../../../../../test/helpers/strapi');
4
+ const { isKnexQuery } = require('../utils/knex');
5
+
6
+ let strapi;
7
+
8
+ describe('knex', () => {
9
+ beforeAll(async () => {
10
+ strapi = await createStrapiInstance();
11
+ });
12
+
13
+ afterAll(async () => {
14
+ await strapi.destroy();
15
+ });
16
+
17
+ describe('isKnexQuery', () => {
18
+ test('knex query: true', () => {
19
+ const res = isKnexQuery(strapi.db.connection('strapi_core_store_settings'));
20
+ expect(res).toBe(true);
21
+ });
22
+
23
+ test('knex raw: true', () => {
24
+ const res = isKnexQuery(strapi.db.connection.raw('SELECT * FROM strapi_core_store_settings'));
25
+ expect(res).toBe(true);
26
+ });
27
+
28
+ test.each([[''], [{}], [[]], [2], [new Date()]])('%s: false', (value) => {
29
+ const res = isKnexQuery(value);
30
+ expect(res).toBe(false);
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ const KnexBuilder = require('knex/lib/query/querybuilder');
4
+ const KnexRaw = require('knex/lib/raw');
5
+
6
+ const isKnexQuery = (value) => {
7
+ return value instanceof KnexBuilder || value instanceof KnexRaw;
8
+ };
9
+
10
+ module.exports = {
11
+ isKnexQuery,
12
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strapi/database",
3
- "version": "4.4.0-rc.0",
3
+ "version": "4.5.0-alpha.0",
4
4
  "description": "Strapi's database layer",
5
5
  "homepage": "https://strapi.io",
6
6
  "bugs": {
@@ -42,5 +42,5 @@
42
42
  "node": ">=14.19.1 <=18.x.x",
43
43
  "npm": ">=6.0.0"
44
44
  },
45
- "gitHead": "57635b60c9a7815830734d85fe76df3ce8ed5898"
45
+ "gitHead": "c9a98c4dbcf3c4f2a449f8d96e7cbe4cd9b1e0f5"
46
46
  }