@strapi/database 4.5.4 → 4.6.0-beta.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.
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const relationsOrderer = require('../relations-orderer');
4
+
5
+ describe('relations orderer', () => {
6
+ test('connect at the end', () => {
7
+ const orderer = relationsOrderer(
8
+ [
9
+ { id: 2, order: 4 },
10
+ { id: 3, order: 10 },
11
+ ],
12
+ 'id',
13
+ 'order'
14
+ );
15
+
16
+ orderer.connect([{ id: 4, position: { end: true } }, { id: 5 }]);
17
+
18
+ expect(orderer.get()).toMatchObject([
19
+ { id: 2, order: 4 },
20
+ { id: 3, order: 10 },
21
+ { id: 4, order: 10.5 },
22
+ { id: 5, order: 10.5 },
23
+ ]);
24
+ });
25
+
26
+ test('connect at the start', () => {
27
+ const orderer = relationsOrderer(
28
+ [
29
+ { id: 2, order: 4 },
30
+ { id: 3, order: 10 },
31
+ ],
32
+ 'id',
33
+ 'order'
34
+ );
35
+
36
+ orderer.connect([{ id: 4, position: { start: true } }]);
37
+
38
+ expect(orderer.get()).toMatchObject([
39
+ { id: 4, order: 0.5 },
40
+ { id: 2, order: 4 },
41
+ { id: 3, order: 10 },
42
+ ]);
43
+ });
44
+
45
+ test('connect multiple relations', () => {
46
+ const orderer = relationsOrderer(
47
+ [
48
+ { id: 2, order: 4 },
49
+ { id: 3, order: 10 },
50
+ ],
51
+ 'id',
52
+ 'order'
53
+ );
54
+
55
+ orderer.connect([
56
+ { id: 4, position: { before: 2 } },
57
+ { id: 4, position: { before: 3 } },
58
+ { id: 5, position: { before: 4 } },
59
+ ]);
60
+
61
+ expect(orderer.get()).toMatchObject([
62
+ { id: 2, order: 4 },
63
+ { id: 5, order: 9.5 },
64
+ { id: 4, order: 9.5 },
65
+ { id: 3, order: 10 },
66
+ ]);
67
+ });
68
+
69
+ test('connect with no initial relations', () => {
70
+ const orderer = relationsOrderer([], 'id', 'order');
71
+
72
+ orderer.connect([
73
+ { id: 1, position: { start: true } },
74
+ { id: 2, position: { start: true } },
75
+ { id: 3, position: { after: 1 } },
76
+ { id: 1, position: { after: 2 } },
77
+ ]);
78
+
79
+ expect(orderer.get()).toMatchObject([
80
+ { id: 2, order: 0.5 },
81
+ { id: 1, order: 0.5 },
82
+ { id: 3, order: 0.5 },
83
+ ]);
84
+ });
85
+ });
@@ -3,6 +3,7 @@
3
3
  const {
4
4
  isUndefined,
5
5
  castArray,
6
+ compact,
6
7
  isNil,
7
8
  has,
8
9
  isString,
@@ -18,6 +19,7 @@ const {
18
19
  isNumber,
19
20
  map,
20
21
  difference,
22
+ uniqBy,
21
23
  } = require('lodash/fp');
22
24
  const types = require('../types');
23
25
  const { createField } = require('../fields');
@@ -37,6 +39,7 @@ const {
37
39
  deleteRelations,
38
40
  cleanOrderColumns,
39
41
  } = require('./regular-relations');
42
+ const relationsOrderer = require('./relations-orderer');
40
43
 
41
44
  const toId = (value) => value.id || value;
42
45
  const toIds = (value) => castArray(value || []).map(toId);
@@ -75,7 +78,10 @@ const toAssocs = (data) => {
75
78
  }
76
79
 
77
80
  return {
78
- connect: toIdArray(data?.connect),
81
+ connect: toIdArray(data?.connect).map((elm) => ({
82
+ id: elm.id,
83
+ position: elm.position ? elm.position : { end: true },
84
+ })),
79
85
  disconnect: toIdArray(data?.disconnect),
80
86
  };
81
87
  };
@@ -561,7 +567,7 @@ const createEntityManager = (db) => {
561
567
  }
562
568
 
563
569
  // prepare new relations to insert
564
- const insert = relsToAdd.map((data) => {
570
+ const insert = uniqBy('id', relsToAdd).map((data) => {
565
571
  return {
566
572
  [joinColumn.name]: id,
567
573
  [inverseJoinColumn.name]: data.id,
@@ -571,11 +577,23 @@ const createEntityManager = (db) => {
571
577
  });
572
578
 
573
579
  // add order value
574
- if (hasOrderColumn(attribute)) {
575
- insert.forEach((rel, idx) => {
576
- rel[orderColumnName] = idx + 1;
580
+ if (cleanRelationData.set && hasOrderColumn(attribute)) {
581
+ insert.forEach((data, idx) => {
582
+ data[orderColumnName] = idx + 1;
583
+ });
584
+ } else if (cleanRelationData.connect && hasOrderColumn(attribute)) {
585
+ // use position attributes to calculate order
586
+ const orderMap = relationsOrderer([], inverseJoinColumn.name, joinTable.orderColumnName)
587
+ .connect(relsToAdd)
588
+ .get()
589
+ // set the order based on the order of the ids
590
+ .reduce((acc, rel, idx) => ({ ...acc, [rel.id]: idx }), {});
591
+
592
+ insert.forEach((row) => {
593
+ row[orderColumnName] = orderMap[row[inverseJoinColumn.name]];
577
594
  });
578
595
  }
596
+
579
597
  // add inv_order value
580
598
  if (hasInverseOrderColumn(attribute)) {
581
599
  const maxResults = await db
@@ -815,27 +833,53 @@ const createEntityManager = (db) => {
815
833
  }
816
834
 
817
835
  // prepare relations to insert
818
- const insert = cleanRelationData.connect.map((relToAdd) => ({
836
+ const insert = uniqBy('id', cleanRelationData.connect).map((relToAdd) => ({
819
837
  [joinColumn.name]: id,
820
838
  [inverseJoinColumn.name]: relToAdd.id,
821
839
  ...(joinTable.on || {}),
822
840
  ...(relToAdd.__pivot || {}),
823
841
  }));
824
842
 
825
- // add order value
826
843
  if (hasOrderColumn(attribute)) {
827
- const orderMax = (
828
- await this.createQueryBuilder(joinTable.name)
829
- .max(orderColumnName)
830
- .where({ [joinColumn.name]: id })
831
- .where(joinTable.on || {})
832
- .first()
833
- .transacting(trx)
834
- .execute()
835
- ).max;
844
+ // Get all adjacent relations and the one with the highest order
845
+ const adjacentRelations = await this.createQueryBuilder(joinTable.name)
846
+ .where({
847
+ $or: [
848
+ {
849
+ [joinColumn.name]: id,
850
+ [inverseJoinColumn.name]: {
851
+ $in: compact(
852
+ cleanRelationData.connect.map(
853
+ (r) => r.position?.after || r.position?.before
854
+ )
855
+ ),
856
+ },
857
+ },
858
+ {
859
+ [joinColumn.name]: id,
860
+ [orderColumnName]: this.createQueryBuilder(joinTable.name)
861
+ .max(orderColumnName)
862
+ .where({ [joinColumn.name]: id })
863
+ .where(joinTable.on || {})
864
+ .transacting(trx)
865
+ .getKnexQuery(),
866
+ },
867
+ ],
868
+ })
869
+ .where(joinTable.on || {})
870
+ .transacting(trx)
871
+ .execute();
836
872
 
837
- insert.forEach((row, idx) => {
838
- row[orderColumnName] = orderMax + idx + 1;
873
+ const orderMap = relationsOrderer(
874
+ adjacentRelations,
875
+ inverseJoinColumn.name,
876
+ joinTable.orderColumnName
877
+ )
878
+ .connect(cleanRelationData.connect)
879
+ .getOrderMap();
880
+
881
+ insert.forEach((row) => {
882
+ row[orderColumnName] = orderMap[row[inverseJoinColumn.name]];
839
883
  });
840
884
  }
841
885
 
@@ -901,7 +945,7 @@ const createEntityManager = (db) => {
901
945
  continue;
902
946
  }
903
947
 
904
- const insert = cleanRelationData.set.map((relToAdd) => ({
948
+ const insert = uniqBy('id', cleanRelationData.set).map((relToAdd) => ({
905
949
  [joinColumn.name]: id,
906
950
  [inverseJoinColumn.name]: relToAdd.id,
907
951
  ...(joinTable.on || {}),
@@ -198,42 +198,60 @@ const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds, transaction
198
198
  return;
199
199
  }
200
200
 
201
+ // Handle databases that don't support window function ROW_NUMBER
202
+ if (!strapi.db.dialect.supportsWindowFunctions()) {
203
+ await cleanOrderColumnsForOldDatabases({ id, attribute, db, inverseRelIds, transaction: trx });
204
+ return;
205
+ }
206
+
207
+ const { joinTable } = attribute;
208
+ const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable;
209
+ const update = [];
210
+ const updateBinding = [];
211
+ const select = ['??'];
212
+ const selectBinding = ['id'];
213
+ const where = [];
214
+ const whereBinding = [];
215
+
216
+ if (hasOrderColumn(attribute) && id) {
217
+ update.push('?? = b.src_order');
218
+ updateBinding.push(orderColumnName);
219
+ select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS src_order');
220
+ selectBinding.push(joinColumn.name, orderColumnName);
221
+ where.push('?? = ?');
222
+ whereBinding.push(joinColumn.name, id);
223
+ }
224
+
225
+ if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) {
226
+ update.push('?? = b.inv_order');
227
+ updateBinding.push(inverseOrderColumnName);
228
+ select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS inv_order');
229
+ selectBinding.push(inverseJoinColumn.name, inverseOrderColumnName);
230
+ where.push(`?? IN (${inverseRelIds.map(() => '?').join(', ')})`);
231
+ whereBinding.push(inverseJoinColumn.name, ...inverseRelIds);
232
+ }
233
+
234
+ // raw query as knex doesn't allow updating from a subquery
235
+ // https://github.com/knex/knex/issues/2504
201
236
  switch (strapi.db.dialect.client) {
202
237
  case 'mysql':
203
- await cleanOrderColumnsForInnoDB({ id, attribute, db, inverseRelIds, transaction: trx });
238
+ await db.connection
239
+ .raw(
240
+ `UPDATE
241
+ ?? as a,
242
+ (
243
+ SELECT ${select.join(', ')}
244
+ FROM ??
245
+ WHERE ${where.join(' OR ')}
246
+ ) AS b
247
+ SET ${update.join(', ')}
248
+ WHERE b.id = a.id`,
249
+ [joinTable.name, ...selectBinding, joinTable.name, ...whereBinding, ...updateBinding]
250
+ )
251
+ .transacting(trx);
204
252
  break;
205
253
  default: {
206
- const { joinTable } = attribute;
207
- const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable;
208
- const update = [];
209
- const updateBinding = [];
210
- const select = ['??'];
211
- const selectBinding = ['id'];
212
- const where = [];
213
- const whereBinding = [];
214
-
215
- if (hasOrderColumn(attribute) && id) {
216
- update.push('?? = b.src_order');
217
- updateBinding.push(orderColumnName);
218
- select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS src_order');
219
- selectBinding.push(joinColumn.name, orderColumnName);
220
- where.push('?? = ?');
221
- whereBinding.push(joinColumn.name, id);
222
- }
223
-
224
- if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) {
225
- update.push('?? = b.inv_order');
226
- updateBinding.push(inverseOrderColumnName);
227
- select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS inv_order');
228
- selectBinding.push(inverseJoinColumn.name, inverseOrderColumnName);
229
- where.push(`?? IN (${inverseRelIds.map(() => '?').join(', ')})`);
230
- whereBinding.push(inverseJoinColumn.name, ...inverseRelIds);
231
- }
232
-
233
254
  const joinTableName = addSchema(joinTable.name);
234
-
235
- // raw query as knex doesn't allow updating from a subquery
236
- // https://github.com/knex/knex/issues/2504
237
255
  await db.connection
238
256
  .raw(
239
257
  `UPDATE ?? as a
@@ -247,29 +265,24 @@ const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds, transaction
247
265
  [joinTableName, ...updateBinding, ...selectBinding, joinTableName, ...whereBinding]
248
266
  )
249
267
  .transacting(trx);
250
-
251
- /*
252
- `UPDATE :joinTable: as a
253
- SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order
254
- FROM (
255
- SELECT
256
- id,
257
- ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order,
258
- ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order
259
- FROM :joinTable:
260
- WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds)
261
- ) AS b
262
- WHERE b.id = a.id`,
263
- */
264
268
  }
269
+ /*
270
+ `UPDATE :joinTable: as a
271
+ SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order
272
+ FROM (
273
+ SELECT
274
+ id,
275
+ ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order,
276
+ ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order
277
+ FROM :joinTable:
278
+ WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds)
279
+ ) AS b
280
+ WHERE b.id = a.id`,
281
+ */
265
282
  }
266
283
  };
267
284
 
268
- /*
269
- * Ensure that orders are following a 1, 2, 3 sequence, without gap.
270
- * The use of a temporary table instead of a window function makes the query compatible with MySQL 5 and prevents some deadlocks to happen in innoDB databases
271
- */
272
- const cleanOrderColumnsForInnoDB = async ({
285
+ const cleanOrderColumnsForOldDatabases = async ({
273
286
  id,
274
287
  attribute,
275
288
  db,
@@ -306,9 +319,6 @@ const cleanOrderColumnsForInnoDB = async ({
306
319
  }
307
320
  )
308
321
  .transacting(trx);
309
-
310
- // raw query as knex doesn't allow updating from a subquery
311
- // https://github.com/knex/knex/issues/2504
312
322
  await db.connection
313
323
  .raw(
314
324
  `UPDATE ?? as a, (SELECT * FROM ??) AS b
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ const _ = require('lodash/fp');
4
+
5
+ /**
6
+ * Responsible for calculating the relations order when connecting them.
7
+ *
8
+ * The connect method takes an array of relations with positional attributes:
9
+ * - before: the id of the relation to connect before
10
+ * - after: the id of the relation to connect after
11
+ * - end: it should be at the end
12
+ * - start: it should be at the start
13
+ *
14
+ * Example:
15
+ * - Having a connect array like:
16
+ * [ { id: 4, before: 2 }, { id: 4, before: 3}, {id: 5, before: 4} ]
17
+ * - With the initial relations:
18
+ * [ { id: 2, order: 4 }, { id: 3, order: 10 } ]
19
+ * - Step by step, going through the connect array, the array of relations would be:
20
+ * [ { id: 4, order: 3.5 }, { id: 2, order: 4 }, { id: 3, order: 10 } ]
21
+ * [ { id: 2, order: 4 }, { id: 4, order: 3.5 }, { id: 3, order: 10 } ]
22
+ * [ { id: 2, order: 4 }, { id: 5, order: 3.5 }, { id: 4, order: 3.5 }, { id: 3, order: 10 } ]
23
+ * - The final step would be to recalculate fractional order values.
24
+ * [ { id: 2, order: 4 }, { id: 5, order: 3.33 }, { id: 4, order: 3.66 }, { id: 3, order: 10 } ]
25
+ *
26
+ * Constraints:
27
+ * - Expects you will never connect a relation before / after one that does not exist
28
+ * - Expect initArr to have all relations referenced in the positional attributes
29
+ *
30
+ * @param {Array<*>} initArr - array of relations to initialize the class with
31
+ * @param {string} idColumn - the column name of the id
32
+ * @param {string} orderColumn - the column name of the order
33
+ * @return {*}
34
+ */
35
+ const relationsOrderer = (initArr, idColumn, orderColumn) => {
36
+ const arr = _.castArray(initArr || []).map((r) => ({
37
+ init: true,
38
+ id: r[idColumn],
39
+ order: r[orderColumn],
40
+ }));
41
+
42
+ const maxOrder = _.maxBy('order', arr)?.order || 0;
43
+
44
+ // TODO: Improve performance by using a map
45
+ const findRelation = (id) => {
46
+ const idx = arr.findIndex((r) => r.id === id);
47
+ return { idx, relation: arr[idx] };
48
+ };
49
+
50
+ const removeRelation = (r) => {
51
+ const { idx } = findRelation(r.id);
52
+ if (idx >= 0) {
53
+ arr.splice(idx, 1);
54
+ }
55
+ };
56
+
57
+ const insertRelation = (r) => {
58
+ let idx;
59
+
60
+ if (r.position?.before) {
61
+ const { idx: _idx, relation } = findRelation(r.position.before);
62
+ if (relation.init) r.order = relation.order - 0.5;
63
+ else r.order = relation.order;
64
+ idx = _idx;
65
+ } else if (r.position?.after) {
66
+ const { idx: _idx, relation } = findRelation(r.position.after);
67
+ if (relation.init) r.order = relation.order + 0.5;
68
+ else r.order = relation.order;
69
+ idx = _idx + 1;
70
+ } else if (r.position?.start) {
71
+ r.order = 0.5;
72
+ idx = 0;
73
+ } else {
74
+ r.order = maxOrder + 0.5;
75
+ idx = arr.length;
76
+ }
77
+
78
+ // Insert the relation in the array
79
+ arr.splice(idx, 0, r);
80
+ };
81
+
82
+ return {
83
+ disconnect(relations) {
84
+ _.castArray(relations).forEach((relation) => {
85
+ removeRelation(relation);
86
+ });
87
+ return this;
88
+ },
89
+ connect(relations) {
90
+ _.castArray(relations).forEach((relation) => {
91
+ this.disconnect(relation);
92
+
93
+ try {
94
+ insertRelation(relation);
95
+ } catch (err) {
96
+ strapi.log.error(err);
97
+ throw new Error(
98
+ `Could not connect ${relation.id}, position ${JSON.stringify(
99
+ relation.position
100
+ )} is invalid`
101
+ );
102
+ }
103
+ });
104
+ return this;
105
+ },
106
+ get() {
107
+ return arr;
108
+ },
109
+ /**
110
+ * Get a map between the relation id and its order
111
+ */
112
+ getOrderMap() {
113
+ return _(arr)
114
+ .groupBy('order')
115
+ .reduce((acc, relations) => {
116
+ if (relations[0]?.init) return acc;
117
+ relations.forEach((relation, idx) => {
118
+ acc[relation.id] = Math.floor(relation.order) + (idx + 1) / (relations.length + 1);
119
+ });
120
+ return acc;
121
+ }, {});
122
+ },
123
+ };
124
+ };
125
+
126
+ module.exports = relationsOrderer;
@@ -136,7 +136,7 @@ const createCompoLinkModelMeta = (baseModelMeta) => {
136
136
  type: 'string',
137
137
  },
138
138
  order: {
139
- type: 'integer',
139
+ type: 'float',
140
140
  column: {
141
141
  unsigned: true,
142
142
  defaultTo: null,
@@ -231,7 +231,7 @@ const createMorphToMany = (attributeName, attribute, meta, metadata) => {
231
231
  type: 'string',
232
232
  },
233
233
  order: {
234
- type: 'integer',
234
+ type: 'float',
235
235
  column: {
236
236
  unsigned: true,
237
237
  },
@@ -485,7 +485,7 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => {
485
485
  // order
486
486
  if (isAnyToMany(attribute)) {
487
487
  metadataSchema.attributes[orderColumnName] = {
488
- type: 'integer',
488
+ type: 'float',
489
489
  column: {
490
490
  unsigned: true,
491
491
  defaultTo: null,
@@ -502,7 +502,7 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => {
502
502
  // inv order
503
503
  if (isBidirectional(attribute) && isManyToAny(attribute)) {
504
504
  metadataSchema.attributes[inverseOrderColumnName] = {
505
- type: 'integer',
505
+ type: 'float',
506
506
  column: {
507
507
  unsigned: true,
508
508
  defaultTo: null,
@@ -53,7 +53,7 @@ const castValue = (value, attribute) => {
53
53
  return value;
54
54
  }
55
55
 
56
- if (types.isScalar(attribute.type)) {
56
+ if (types.isScalar(attribute.type) && !isKnexQuery(value)) {
57
57
  const field = createField(attribute);
58
58
 
59
59
  return value === null ? null : field.toDB(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strapi/database",
3
- "version": "4.5.4",
3
+ "version": "4.6.0-beta.0",
4
4
  "description": "Strapi's database layer",
5
5
  "homepage": "https://strapi.io",
6
6
  "bugs": {
@@ -43,5 +43,5 @@
43
43
  "node": ">=14.19.1 <=18.x.x",
44
44
  "npm": ">=6.0.0"
45
45
  },
46
- "gitHead": "8716ecc920130db5341b0904cf868c6e6b581a5d"
46
+ "gitHead": "c0c3365ad801d088a6ab6c4eb95a014078429747"
47
47
  }