@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.
- package/lib/entity-manager/entity-repository.js +8 -0
- package/lib/entity-manager/index.js +144 -11
- package/lib/entity-manager/regular-relations.js +94 -0
- package/lib/entity-manager/relations/cloning/regular-relations.js +76 -0
- package/lib/fields/shared/parsers.js +2 -0
- package/lib/metadata/relations.js +3 -1
- package/package.json +3 -3
|
@@ -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
|
-
|
|
6
|
+
difference,
|
|
7
|
+
differenceWith,
|
|
8
|
+
flow,
|
|
8
9
|
has,
|
|
9
|
-
isString,
|
|
10
|
-
isInteger,
|
|
11
|
-
pick,
|
|
12
|
-
isPlainObject,
|
|
13
|
-
isEmpty,
|
|
14
10
|
isArray,
|
|
15
|
-
|
|
16
|
-
uniqWith,
|
|
11
|
+
isEmpty,
|
|
17
12
|
isEqual,
|
|
18
|
-
|
|
13
|
+
isInteger,
|
|
14
|
+
isNil,
|
|
15
|
+
isNull,
|
|
19
16
|
isNumber,
|
|
17
|
+
isPlainObject,
|
|
18
|
+
isString,
|
|
19
|
+
isUndefined,
|
|
20
20
|
map,
|
|
21
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
49
|
+
"gitHead": "8d325a695386ab9b578b360bf768cfa25173050f"
|
|
50
50
|
}
|