@strapi/database 4.11.2 → 4.11.4

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.
@@ -80,6 +80,10 @@ const createRepository = (uid, db) => {
80
80
  return db.entityManager.updateMany(uid, params);
81
81
  },
82
82
 
83
+ clone(id, params) {
84
+ return db.entityManager.clone(uid, id, params);
85
+ },
86
+
83
87
  delete(params) {
84
88
  return db.entityManager.delete(uid, params);
85
89
  },
@@ -111,6 +115,10 @@ const createRepository = (uid, db) => {
111
115
  return db.entityManager.deleteRelations(uid, id);
112
116
  },
113
117
 
118
+ cloneRelations(targetId, sourceId, params) {
119
+ return db.entityManager.cloneRelations(uid, targetId, sourceId, params);
120
+ },
121
+
114
122
  populate(entity, populate) {
115
123
  return db.entityManager.populate(uid, entity, populate);
116
124
  },
@@ -1,32 +1,38 @@
1
1
  'use strict';
2
2
 
3
3
  const {
4
- isUndefined,
5
4
  castArray,
6
5
  compact,
7
- isNil,
6
+ difference,
7
+ differenceWith,
8
+ flow,
8
9
  has,
9
- isString,
10
- isInteger,
11
- pick,
12
- isPlainObject,
13
- isEmpty,
14
10
  isArray,
15
- isNull,
16
- uniqWith,
11
+ isEmpty,
17
12
  isEqual,
18
- differenceWith,
13
+ isInteger,
14
+ isNil,
15
+ isNull,
19
16
  isNumber,
17
+ isPlainObject,
18
+ isString,
19
+ isUndefined,
20
20
  map,
21
- difference,
21
+ mergeWith,
22
+ omit,
23
+ pick,
22
24
  uniqBy,
25
+ uniqWith,
23
26
  } = require('lodash/fp');
27
+
28
+ const { mapAsync } = require('@strapi/utils');
24
29
  const types = require('../types');
25
30
  const { createField } = require('../fields');
26
31
  const { createQueryBuilder } = require('../query');
27
32
  const { createRepository } = require('./entity-repository');
28
33
  const { deleteRelatedMorphOneRelationsAfterMorphToManyUpdate } = require('./morph-relations');
29
34
  const {
35
+ isPolymorphic,
30
36
  isBidirectional,
31
37
  isAnyToOne,
32
38
  isOneToAny,
@@ -40,6 +46,11 @@ const {
40
46
  cleanOrderColumns,
41
47
  } = require('./regular-relations');
42
48
  const { relationsOrderer } = require('./relations-orderer');
49
+ const {
50
+ replaceRegularRelations,
51
+ cloneRegularRelations,
52
+ } = require('./relations/cloning/regular-relations');
53
+ const { DatabaseError } = require('../errors');
43
54
 
44
55
  const toId = (value) => value.id || value;
45
56
  const toIds = (value) => castArray(value || []).map(toId);
@@ -359,6 +370,61 @@ const createEntityManager = (db) => {
359
370
  return result;
360
371
  },
361
372
 
373
+ async clone(uid, cloneId, params = {}) {
374
+ const states = await db.lifecycles.run('beforeCreate', uid, { params });
375
+
376
+ const metadata = db.metadata.get(uid);
377
+ const { data } = params;
378
+
379
+ if (!isNil(data) && !isPlainObject(data)) {
380
+ throw new Error('Create expects a data object');
381
+ }
382
+
383
+ // TODO: Handle join columns?
384
+ const entity = await this.findOne(uid, { where: { id: cloneId } });
385
+
386
+ const dataToInsert = flow(
387
+ // Omit unwanted properties
388
+ omit(['id', 'created_at', 'updated_at']),
389
+ // Merge with provided data, set attribute to null if data attribute is null
390
+ mergeWith(data || {}, (original, override) => (override === null ? override : original)),
391
+ // Process data with metadata
392
+ (entity) => processData(metadata, entity, { withDefaults: true })
393
+ )(entity);
394
+
395
+ const res = await this.createQueryBuilder(uid).insert(dataToInsert).execute();
396
+
397
+ const id = res[0].id || res[0];
398
+
399
+ const trx = await strapi.db.transaction();
400
+ try {
401
+ const cloneAttrs = Object.entries(metadata.attributes).reduce((acc, [attrName, attr]) => {
402
+ // TODO: handle components in the db layer
403
+ if (attr.type === 'relation' && attr.joinTable && !attr.component) {
404
+ acc.push(attrName);
405
+ }
406
+ return acc;
407
+ }, []);
408
+
409
+ await this.cloneRelations(uid, id, cloneId, data, { cloneAttrs, transaction: trx.get() });
410
+ await trx.commit();
411
+ } catch (e) {
412
+ await trx.rollback();
413
+ await this.createQueryBuilder(uid).where({ id }).delete().execute();
414
+ throw e;
415
+ }
416
+
417
+ const result = await this.findOne(uid, {
418
+ where: { id },
419
+ select: params.select,
420
+ populate: params.populate,
421
+ });
422
+
423
+ await db.lifecycles.run('afterCreate', uid, { params, result }, states);
424
+
425
+ return result;
426
+ },
427
+
362
428
  async delete(uid, params = {}) {
363
429
  const states = await db.lifecycles.run('beforeDelete', uid, { params });
364
430
 
@@ -1167,6 +1233,73 @@ const createEntityManager = (db) => {
1167
1233
  }
1168
1234
  },
1169
1235
 
1236
+ // TODO: Clone polymorphic relations
1237
+ /**
1238
+ *
1239
+ * @param {string} uid - uid of the entity to clone
1240
+ * @param {number} targetId - id of the entity to clone into
1241
+ * @param {number} sourceId - id of the entity to clone from
1242
+ * @param {object} opt
1243
+ * @param {object} opt.cloneAttrs - key value pair of attributes to clone
1244
+ * @param {object} opt.transaction - transaction to use
1245
+ * @example cloneRelations('user', 3, 1, { cloneAttrs: ["comments"]})
1246
+ * @example cloneRelations('post', 5, 2, { cloneAttrs: ["comments", "likes"] })
1247
+ */
1248
+ async cloneRelations(uid, targetId, sourceId, data, { cloneAttrs = [], transaction }) {
1249
+ const { attributes } = db.metadata.get(uid);
1250
+
1251
+ if (!attributes) {
1252
+ return;
1253
+ }
1254
+
1255
+ await mapAsync(cloneAttrs, async (attrName) => {
1256
+ const attribute = attributes[attrName];
1257
+
1258
+ if (attribute.type !== 'relation') {
1259
+ throw new DatabaseError(
1260
+ `Attribute ${attrName} is not a relation attribute. Cloning relations is only supported for relation attributes.`
1261
+ );
1262
+ }
1263
+
1264
+ if (isPolymorphic(attribute)) {
1265
+ // TODO: add support for cloning polymorphic relations
1266
+ return;
1267
+ }
1268
+
1269
+ if (attribute.joinColumn) {
1270
+ // TODO: add support for cloning oneToMany relations on the owning side
1271
+ return;
1272
+ }
1273
+
1274
+ if (!attribute.joinTable) {
1275
+ return;
1276
+ }
1277
+
1278
+ let omitIds = [];
1279
+ if (has(attrName, data)) {
1280
+ const cleanRelationData = toAssocs(data[attrName]);
1281
+
1282
+ // Don't clone if the relation attr is being set
1283
+ if (cleanRelationData.set) {
1284
+ return;
1285
+ }
1286
+
1287
+ // Disconnected relations don't need to be cloned
1288
+ if (cleanRelationData.disconnect) {
1289
+ omitIds = toIds(cleanRelationData.disconnect);
1290
+ }
1291
+ }
1292
+
1293
+ if (isOneToAny(attribute) && isBidirectional(attribute)) {
1294
+ await replaceRegularRelations({ targetId, sourceId, attribute, omitIds, transaction });
1295
+ } else {
1296
+ await cloneRegularRelations({ targetId, sourceId, attribute, transaction });
1297
+ }
1298
+ });
1299
+
1300
+ await this.updateRelations(uid, targetId, data, { transaction });
1301
+ },
1302
+
1170
1303
  // TODO: add lifecycle events
1171
1304
  async populate(uid, entity, populate) {
1172
1305
  const entry = await this.findOne(uid, {
@@ -379,9 +379,103 @@ const cleanOrderColumnsForOldDatabases = async ({
379
379
  }
380
380
  };
381
381
 
382
+ /**
383
+ * Use this when a relation is added or removed and its inverse order column
384
+ * needs to be re-calculated
385
+ *
386
+ * Example: In this following table
387
+ *
388
+ * | joinColumn | inverseJoinColumn | order | inverseOrder |
389
+ * | --------------- | -------- | ----------- | ------------------ |
390
+ * | 1 | 1 | 1 | 1 |
391
+ * | 2 | 1 | 3 | 2 |
392
+ * | 2 | 2 | 3 | 1 |
393
+ *
394
+ * You add a new relation { joinColumn: 1, inverseJoinColumn: 2 }
395
+ *
396
+ * | joinColumn | inverseJoinColumn | order | inverseOrder |
397
+ * | --------------- | -------- | ----------- | ------------------ |
398
+ * | 1 | 1 | 1 | 1 |
399
+ * | 1 | 2 | 2 | 1 | <- inverseOrder should be 2
400
+ * | 2 | 1 | 3 | 2 |
401
+ * | 2 | 2 | 3 | 1 |
402
+ *
403
+ * This function would make such update, so all inverse order columns related
404
+ * to the given id (1 in this example) are following a 1, 2, 3 sequence, without gap.
405
+ *
406
+ * @param {Object} params
407
+ * @param {string} params.id - entity id to find which inverse order column to clean
408
+ * @param {Object} params.attribute - attribute of the relation
409
+ * @param {Object} params.trx - knex transaction
410
+ *
411
+ */
412
+
413
+ const cleanInverseOrderColumn = async ({ id, attribute, trx }) => {
414
+ const con = strapi.db.connection;
415
+ const { joinTable } = attribute;
416
+ const { joinColumn, inverseJoinColumn, inverseOrderColumnName } = joinTable;
417
+
418
+ switch (strapi.db.dialect.client) {
419
+ /*
420
+ UPDATE `:joinTableName` AS `t1`
421
+ JOIN (
422
+ SELECT
423
+ `inverseJoinColumn`,
424
+ MAX(`:inverseOrderColumnName`) AS `max_inv_order`
425
+ FROM `:joinTableName`
426
+ GROUP BY `:inverseJoinColumn`
427
+ ) AS `t2`
428
+ ON `t1`.`:inverseJoinColumn` = `t2`.`:inverseJoinColumn`
429
+ SET `t1`.`:inverseOrderColumnNAme` = `t2`.`max_inv_order` + 1
430
+ WHERE `t1`.`:joinColumnName` = :id;
431
+ */
432
+ case 'mysql': {
433
+ // Group by the inverse join column and get the max value of the inverse order column
434
+ const subQuery = con(joinTable.name)
435
+ .select(inverseJoinColumn.name)
436
+ .max(inverseOrderColumnName, { as: 'max_inv_order' })
437
+ .groupBy(inverseJoinColumn.name)
438
+ .as('t2');
439
+
440
+ // Update ids with the new inverse order
441
+ await con(`${joinTable.name} as t1`)
442
+ .join(subQuery, `t1.${inverseJoinColumn.name}`, '=', `t2.${inverseJoinColumn.name}`)
443
+ .where(joinColumn.name, id)
444
+ .update({
445
+ [inverseOrderColumnName]: con.raw('t2.max_inv_order + 1'),
446
+ })
447
+ .transacting(trx);
448
+ break;
449
+ }
450
+ default: {
451
+ /*
452
+ UPDATE `:joinTableName` as `t1`
453
+ SET `:inverseOrderColumnName` = (
454
+ SELECT max(`:inverseOrderColumnName`) + 1
455
+ FROM `:joinTableName` as `t2`
456
+ WHERE t2.:inverseJoinColumn = t1.:inverseJoinColumn
457
+ )
458
+ WHERE `t1`.`:joinColumnName` = :id
459
+ */
460
+ // New inverse order will be the max value + 1
461
+ const selectMaxInverseOrder = con.raw(`max(${inverseOrderColumnName}) + 1`);
462
+
463
+ const subQuery = con(`${joinTable.name} as t2`)
464
+ .select(selectMaxInverseOrder)
465
+ .whereRaw(`t2.${inverseJoinColumn.name} = t1.${inverseJoinColumn.name}`);
466
+
467
+ await con(`${joinTable.name} as t1`)
468
+ .where(`t1.${joinColumn.name}`, id)
469
+ .update({ [inverseOrderColumnName]: subQuery })
470
+ .transacting(trx);
471
+ }
472
+ }
473
+ };
474
+
382
475
  module.exports = {
383
476
  deletePreviousOneToAnyRelations,
384
477
  deletePreviousAnyToOneRelations,
385
478
  deleteRelations,
386
479
  cleanOrderColumns,
480
+ cleanInverseOrderColumn,
387
481
  };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ const { cleanInverseOrderColumn } = require('../../regular-relations');
4
+
5
+ const replaceRegularRelations = async ({
6
+ targetId,
7
+ sourceId,
8
+ attribute,
9
+ omitIds,
10
+ transaction: trx,
11
+ }) => {
12
+ const { joinTable } = attribute;
13
+ const { joinColumn, inverseJoinColumn } = joinTable;
14
+
15
+ // We are effectively stealing the relation from the cloned entity
16
+ await strapi.db.entityManager
17
+ .createQueryBuilder(joinTable.name)
18
+ .update({ [joinColumn.name]: targetId })
19
+ .where({ [joinColumn.name]: sourceId })
20
+ .where({ $not: { [inverseJoinColumn.name]: omitIds } })
21
+ .onConflict([joinColumn.name, inverseJoinColumn.name])
22
+ .ignore()
23
+ .transacting(trx)
24
+ .execute();
25
+ };
26
+
27
+ const cloneRegularRelations = async ({ targetId, sourceId, attribute, transaction: trx }) => {
28
+ const { joinTable } = attribute;
29
+ const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable;
30
+ const connection = strapi.db.getConnection();
31
+
32
+ // Get the columns to select
33
+ const columns = [joinColumn.name, inverseJoinColumn.name];
34
+ if (orderColumnName) columns.push(orderColumnName);
35
+ if (inverseOrderColumnName) columns.push(inverseOrderColumnName);
36
+ if (joinTable.on) columns.push(...Object.keys(joinTable.on));
37
+
38
+ const selectStatement = connection
39
+ .select(
40
+ // Override joinColumn with the new id
41
+ { [joinColumn.name]: targetId },
42
+ // The rest of columns will be the same
43
+ ...columns.slice(1)
44
+ )
45
+ .where(joinColumn.name, sourceId)
46
+ .from(joinTable.name)
47
+ .toSQL();
48
+
49
+ // Insert the clone relations
50
+ await strapi.db.entityManager
51
+ .createQueryBuilder(joinTable.name)
52
+ .insert(
53
+ strapi.db.connection.raw(
54
+ `(${columns.join(',')}) ${selectStatement.sql}`,
55
+ selectStatement.bindings
56
+ )
57
+ )
58
+ .onConflict([joinColumn.name, inverseJoinColumn.name])
59
+ .ignore()
60
+ .transacting(trx)
61
+ .execute();
62
+
63
+ // Clean the inverse order column
64
+ if (inverseOrderColumnName) {
65
+ await cleanInverseOrderColumn({
66
+ id: targetId,
67
+ attribute,
68
+ trx,
69
+ });
70
+ }
71
+ };
72
+
73
+ module.exports = {
74
+ replaceRegularRelations,
75
+ cloneRegularRelations,
76
+ };
@@ -25,6 +25,8 @@ const parseDateTimeOrTimestamp = (value) => {
25
25
  };
26
26
 
27
27
  const parseDate = (value) => {
28
+ if (dateFns.isDate(value)) return dateFns.format(value, 'yyyy-MM-dd');
29
+
28
30
  const found = isString(value) ? value.match(PARTIAL_DATE_REGEX) || [] : [];
29
31
  const extractedValue = found[0];
30
32
 
@@ -9,6 +9,8 @@ const _ = require('lodash/fp');
9
9
  const hasInversedBy = _.has('inversedBy');
10
10
  const hasMappedBy = _.has('mappedBy');
11
11
 
12
+ const isPolymorphic = (attribute) =>
13
+ ['morphOne', 'morphMany', 'morphToOne', 'morphToMany'].includes(attribute.relation);
12
14
  const isOneToAny = (attribute) => ['oneToOne', 'oneToMany'].includes(attribute.relation);
13
15
  const isManyToAny = (attribute) => ['manyToMany', 'manyToOne'].includes(attribute.relation);
14
16
  const isAnyToOne = (attribute) => ['oneToOne', 'manyToOne'].includes(attribute.relation);
@@ -564,7 +566,7 @@ const hasInverseOrderColumn = (attribute) => isBidirectional(attribute) && isMan
564
566
 
565
567
  module.exports = {
566
568
  createRelation,
567
-
569
+ isPolymorphic,
568
570
  isBidirectional,
569
571
  isOneToAny,
570
572
  isManyToAny,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strapi/database",
3
- "version": "4.11.2",
3
+ "version": "4.11.4",
4
4
  "description": "Strapi's database layer",
5
5
  "homepage": "https://strapi.io",
6
6
  "bugs": {
@@ -33,7 +33,7 @@
33
33
  "lint": "run -T eslint ."
34
34
  },
35
35
  "dependencies": {
36
- "@strapi/utils": "4.11.2",
36
+ "@strapi/utils": "4.11.4",
37
37
  "date-fns": "2.30.0",
38
38
  "debug": "4.3.4",
39
39
  "fs-extra": "10.0.0",
@@ -46,5 +46,5 @@
46
46
  "node": ">=14.19.1 <=18.x.x",
47
47
  "npm": ">=6.0.0"
48
48
  },
49
- "gitHead": "6f7c815c2bbe41dda7d77136eb8df736c028ff67"
49
+ "gitHead": "8d325a695386ab9b578b360bf768cfa25173050f"
50
50
  }