@strapi/database 4.6.0-beta.1 → 4.6.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.
@@ -4,6 +4,7 @@ const _ = require('lodash/fp');
4
4
 
5
5
  const { DatabaseError } = require('../errors');
6
6
  const helpers = require('./helpers');
7
+ const transactionCtx = require('../transaction-context');
7
8
 
8
9
  const createQueryBuilder = (uid, db, initialState = {}) => {
9
10
  const meta = db.metadata.get(uid);
@@ -473,10 +474,18 @@ const createQueryBuilder = (uid, db, initialState = {}) => {
473
474
  try {
474
475
  const qb = this.getKnexQuery();
475
476
 
477
+ if (transactionCtx.get()) {
478
+ qb.transacting(transactionCtx.get());
479
+ }
480
+
476
481
  const rows = await qb;
477
482
 
478
483
  if (state.populate && !_.isNil(rows)) {
479
- await helpers.applyPopulate(_.castArray(rows), state.populate, { qb: this, uid, db });
484
+ await helpers.applyPopulate(_.castArray(rows), state.populate, {
485
+ qb: this,
486
+ uid,
487
+ db,
488
+ });
480
489
  }
481
490
 
482
491
  let results = rows;
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ const { createStrapiInstance } = require('../../../../../test/helpers/strapi');
4
+
5
+ let strapi;
6
+
7
+ describe('transactions', () => {
8
+ let original;
9
+ beforeAll(async () => {
10
+ strapi = await createStrapiInstance();
11
+ original = await strapi.db
12
+ .queryBuilder('strapi::core-store')
13
+ .select(['*'])
14
+ .where({ id: 1 })
15
+ .execute();
16
+ });
17
+
18
+ afterAll(async () => {
19
+ await strapi.destroy();
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await strapi.db
24
+ .queryBuilder('strapi::core-store')
25
+ .update({
26
+ key: original[0].key,
27
+ })
28
+ .where({ id: 1 })
29
+ .execute();
30
+ });
31
+
32
+ describe('using a transaction method', () => {
33
+ test('commits successfully', async () => {
34
+ await strapi.db.transaction(async () => {
35
+ await strapi.db
36
+ .queryBuilder('strapi::core-store')
37
+ .update({
38
+ key: 'wrong key',
39
+ })
40
+ .where({ id: 1 })
41
+ .execute();
42
+
43
+ await strapi.db
44
+ .queryBuilder('strapi::core-store')
45
+ .update({
46
+ key: 'new key',
47
+ })
48
+ .where({ id: 1 })
49
+ .execute();
50
+ });
51
+
52
+ const end = await strapi.db
53
+ .queryBuilder('strapi::core-store')
54
+ .select(['*'])
55
+ .where({ id: 1 })
56
+ .execute();
57
+
58
+ expect(end[0].key).toEqual('new key');
59
+ });
60
+
61
+ test('rollback successfully', async () => {
62
+ try {
63
+ await strapi.db.transaction(async () => {
64
+ // this is valid
65
+ await strapi.db
66
+ .queryBuilder('strapi::core-store')
67
+ .update({
68
+ key: 'wrong key',
69
+ })
70
+ .where({ id: 1 })
71
+ .execute();
72
+
73
+ // this throws
74
+ await strapi.db
75
+ .queryBuilder('invalid_uid')
76
+ .update({
77
+ key: 'bad key',
78
+ invalid_key: 'error',
79
+ })
80
+ .where({ id: 1 })
81
+ .execute();
82
+ });
83
+
84
+ expect('this should not be reached').toBe(false);
85
+ } catch (e) {
86
+ // do nothing
87
+ }
88
+
89
+ const end = await strapi.db
90
+ .queryBuilder('strapi::core-store')
91
+ .select(['*'])
92
+ .where({ id: 1 })
93
+ .execute();
94
+
95
+ expect(end[0].key).toEqual(original[0].key);
96
+ });
97
+
98
+ test('nested rollback -> rollback works', async () => {
99
+ try {
100
+ await strapi.db.transaction(async () => {
101
+ // this is valid
102
+ await strapi.db
103
+ .queryBuilder('strapi::core-store')
104
+ .update({
105
+ key: 'changed key',
106
+ })
107
+ .where({ id: 1 })
108
+ .execute();
109
+
110
+ // here we'll make a nested transaction that throws and then confirm we still have "changed key" from above
111
+ try {
112
+ await strapi.db.transaction(async () => {
113
+ await strapi.db
114
+ .queryBuilder('strapi::core-store')
115
+ .update({
116
+ key: 'changed key - nested',
117
+ })
118
+ .where({ id: 1 })
119
+ .execute();
120
+
121
+ // this should throw and roll back
122
+ await strapi.db
123
+ .queryBuilder('invalid_uid')
124
+ .update({
125
+ invalid_key: 'error',
126
+ })
127
+ .where({ id: 1 })
128
+ .execute();
129
+ });
130
+ } catch (e) {
131
+ // do nothing
132
+ }
133
+
134
+ // should equal the result from above
135
+ const result = await strapi.db
136
+ .queryBuilder('strapi::core-store')
137
+ .select(['*'])
138
+ .where({ id: 1 })
139
+ .execute();
140
+
141
+ expect(result[0].key).toEqual('changed key');
142
+
143
+ // this throws
144
+ await strapi.db
145
+ .queryBuilder('invalid_uid')
146
+ .update({
147
+ key: original[0].key,
148
+ invalid_key: 'error',
149
+ })
150
+ .where({ id: 1 })
151
+ .execute();
152
+ });
153
+
154
+ expect('this should not be reached').toBe(false);
155
+ } catch (e) {
156
+ // do nothing
157
+ }
158
+
159
+ const end = await strapi.db
160
+ .queryBuilder('strapi::core-store')
161
+ .select(['*'])
162
+ .where({ id: 1 })
163
+ .execute();
164
+
165
+ expect(end[0].key).toEqual(original[0].key);
166
+ });
167
+
168
+ test('nested commit -> rollback works', async () => {
169
+ try {
170
+ await strapi.db.transaction(async () => {
171
+ // this is valid
172
+ await strapi.db
173
+ .queryBuilder('strapi::core-store')
174
+ .update({
175
+ key: 'changed key',
176
+ })
177
+ .where({ id: 1 })
178
+ .execute();
179
+
180
+ // here we'll make a nested transaction that works, and then later we'll rollback the outer transaction
181
+ try {
182
+ await strapi.db.transaction(async () => {
183
+ await strapi.db
184
+ .queryBuilder('strapi::core-store')
185
+ .update({
186
+ key: 'changed key - nested',
187
+ })
188
+ .where({ id: 1 })
189
+ .execute();
190
+ });
191
+ } catch (e) {
192
+ // do nothing
193
+ }
194
+
195
+ // should equal the result from above
196
+ const result = await strapi.db
197
+ .queryBuilder('strapi::core-store')
198
+ .select(['*'])
199
+ .where({ id: 1 })
200
+ .execute();
201
+
202
+ expect(result[0].key).toEqual('changed key - nested');
203
+
204
+ // this throws
205
+ await strapi.db
206
+ .queryBuilder('invalid_uid')
207
+ .update({
208
+ key: original[0].key,
209
+ invalid_key: 'error',
210
+ })
211
+ .where({ id: 1 })
212
+ .execute();
213
+ });
214
+
215
+ expect('this should not be reached').toBe(false);
216
+ } catch (e) {
217
+ // do nothing
218
+ }
219
+
220
+ const end = await strapi.db
221
+ .queryBuilder('strapi::core-store')
222
+ .select(['*'])
223
+ .where({ id: 1 })
224
+ .execute();
225
+
226
+ expect(end[0].key).toEqual(original[0].key);
227
+ });
228
+ });
229
+
230
+ describe('using a transaction object', () => {
231
+ test('commits successfully', async () => {
232
+ const trx = await strapi.db.transaction();
233
+
234
+ try {
235
+ await strapi.db
236
+ .queryBuilder('strapi::core-store')
237
+ .update({
238
+ key: 'wrong key',
239
+ })
240
+ .where({ id: 1 })
241
+ .transacting(trx.get())
242
+ .execute();
243
+
244
+ await strapi.db
245
+ .queryBuilder('strapi::core-store')
246
+ .update({
247
+ key: original[0].key,
248
+ })
249
+ .where({ id: 1 })
250
+ .transacting(trx.get())
251
+ .execute();
252
+
253
+ await trx.commit();
254
+ } catch (e) {
255
+ await trx.rollback();
256
+ console.log(e.message);
257
+ expect('this should not be reached').toBe(false);
258
+ }
259
+
260
+ const end = await strapi.db
261
+ .queryBuilder('strapi::core-store')
262
+ .select(['*'])
263
+ .where({ id: 1 })
264
+ .execute();
265
+
266
+ expect(end[0].key).toEqual('strapi_content_types_schema');
267
+ });
268
+
269
+ test('rollback successfully', async () => {
270
+ const trx = await strapi.db.transaction();
271
+
272
+ try {
273
+ await strapi.db
274
+ .queryBuilder('strapi::core-store')
275
+ .update({
276
+ key: 'wrong key',
277
+ })
278
+ .where({ id: 1 })
279
+ .transacting(trx.get())
280
+ .execute();
281
+
282
+ // this query should throw because it has errors
283
+ await strapi.db
284
+ .queryBuilder('invalid_uid')
285
+ .update({
286
+ key: 123,
287
+ key_not_here: 'this should error',
288
+ })
289
+ .where({ id: 'this should error' })
290
+ .transacting(trx.get())
291
+ .execute();
292
+
293
+ await trx.commit();
294
+ expect('this should not be reached').toBe(false);
295
+ } catch (e) {
296
+ await trx.rollback();
297
+ }
298
+
299
+ const end = await strapi.db
300
+ .queryBuilder('strapi::core-store')
301
+ .select(['*'])
302
+ .where({ id: 1 })
303
+ .execute();
304
+
305
+ expect(end[0].key).toEqual('strapi_content_types_schema');
306
+ });
307
+ });
308
+ });
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ const { AsyncLocalStorage } = require('async_hooks');
4
+
5
+ const storage = new AsyncLocalStorage();
6
+
7
+ const transactionCtx = {
8
+ async run(store, cb) {
9
+ return storage.run(store, cb);
10
+ },
11
+
12
+ get() {
13
+ return storage.getStore();
14
+ },
15
+ };
16
+
17
+ module.exports = transactionCtx;
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ const { validateRelations } = require('./relations');
4
+
5
+ /**
6
+ * Validate if the database is in a valid state before starting the server.
7
+ *
8
+ * @param {*} db - Database instance
9
+ */
10
+ async function validateDatabase(db) {
11
+ const relationErrors = await validateRelations(db);
12
+ const errorList = [...relationErrors];
13
+
14
+ if (errorList.length > 0) {
15
+ errorList.forEach((error) => strapi.log.error(error));
16
+ throw new Error('There are errors in some of your models. Please check the logs above.');
17
+ }
18
+ }
19
+
20
+ module.exports = { validateDatabase };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const types = require('../../types');
4
+ const { getJoinTableName } = require('../../metadata/relations');
5
+
6
+ const getLinksWithoutMappedBy = (db) => {
7
+ const relationsToUpdate = {};
8
+
9
+ db.metadata.forEach((contentType) => {
10
+ const attributes = contentType.attributes;
11
+
12
+ // For each relation attribute, add the joinTable name to tablesToUpdate
13
+ Object.values(attributes).forEach((attribute) => {
14
+ if (!types.isRelation(attribute.type)) return;
15
+
16
+ if (attribute.inversedBy) {
17
+ const invRelation = db.metadata.get(attribute.target).attributes[attribute.inversedBy];
18
+
19
+ // Both relations use inversedBy.
20
+ if (invRelation.inversedBy) {
21
+ relationsToUpdate[attribute.joinTable.name] = {
22
+ relation: attribute,
23
+ invRelation,
24
+ };
25
+ }
26
+ }
27
+ });
28
+ });
29
+
30
+ return Object.values(relationsToUpdate);
31
+ };
32
+
33
+ const isLinkTableEmpty = async (db, linkTableName) => {
34
+ // If the table doesn't exist, it's empty
35
+ const exists = await db.getConnection().schema.hasTable(linkTableName);
36
+ if (!exists) return true;
37
+
38
+ const result = await db.getConnection().count('* as count').from(linkTableName);
39
+ return Number(result[0].count) === 0;
40
+ };
41
+
42
+ /**
43
+ * Validates bidirectional relations before starting the server.
44
+ * - If both sides use inversedBy, one of the sides must switch to mappedBy.
45
+ * When this happens, two join tables exist in the database.
46
+ * This makes sure you switch the side which does not delete any data.
47
+ *
48
+ * @param {*} db
49
+ * @return {*}
50
+ */
51
+ const validateBidirectionalRelations = async (db) => {
52
+ const invalidLinks = getLinksWithoutMappedBy(db);
53
+ const errorList = [];
54
+
55
+ for (const { relation, invRelation } of invalidLinks) {
56
+ const contentType = db.metadata.get(invRelation.target);
57
+ const invContentType = db.metadata.get(relation.target);
58
+
59
+ // Generate the join table name based on the relation target table and attribute name.
60
+ const joinTableName = getJoinTableName(contentType.tableName, invRelation.inversedBy);
61
+ const inverseJoinTableName = getJoinTableName(invContentType.tableName, relation.inversedBy);
62
+
63
+ const joinTableEmpty = await isLinkTableEmpty(db, joinTableName);
64
+ const inverseJoinTableEmpty = await isLinkTableEmpty(db, inverseJoinTableName);
65
+
66
+ if (joinTableEmpty) {
67
+ process.emitWarning(
68
+ `Error on attribute "${invRelation.inversedBy}" in model "${contentType.singularName}" (${contentType.uid}).` +
69
+ ` Please modify your ${contentType.singularName} schema by renaming the key "inversedBy" to "mappedBy".` +
70
+ ` Ex: { "inversedBy": "${relation.inversedBy}" } -> { "mappedBy": "${relation.inversedBy}" }`
71
+ );
72
+ } else if (inverseJoinTableEmpty) {
73
+ // Its safe to delete the inverse join table
74
+ process.emitWarning(
75
+ `Error on attribute "${relation.inversedBy}" in model "${invContentType.singularName}" (${invContentType.uid}).` +
76
+ ` Please modify your ${invContentType.singularName} schema by renaming the key "inversedBy" to "mappedBy".` +
77
+ ` Ex: { "inversedBy": "${invRelation.inversedBy}" } -> { "mappedBy": "${invRelation.inversedBy}" }`
78
+ );
79
+ } else {
80
+ // Both sides have data in the join table
81
+ }
82
+ }
83
+
84
+ return errorList;
85
+ };
86
+
87
+ module.exports = {
88
+ validateBidirectionalRelations,
89
+ };
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ const { validateBidirectionalRelations } = require('./bidirectional');
4
+
5
+ /**
6
+ * Validates if relations data and tables are in a valid state before
7
+ * starting the server.
8
+ */
9
+ const validateRelations = async (db) => {
10
+ const bidirectionalRelationsErrors = await validateBidirectionalRelations(db);
11
+ return [...bidirectionalRelationsErrors];
12
+ };
13
+
14
+ module.exports = { validateRelations };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strapi/database",
3
- "version": "4.6.0-beta.1",
3
+ "version": "4.6.0",
4
4
  "description": "Strapi's database layer",
5
5
  "homepage": "https://strapi.io",
6
6
  "bugs": {
@@ -31,10 +31,10 @@
31
31
  "test:unit": "jest --verbose"
32
32
  },
33
33
  "dependencies": {
34
- "date-fns": "2.29.2",
34
+ "date-fns": "2.29.3",
35
35
  "debug": "4.3.4",
36
36
  "fs-extra": "10.0.0",
37
- "knex": "1.0.7",
37
+ "knex": "2.4.0",
38
38
  "lodash": "4.17.21",
39
39
  "semver": "7.3.8",
40
40
  "umzug": "3.1.1"
@@ -43,5 +43,5 @@
43
43
  "node": ">=14.19.1 <=18.x.x",
44
44
  "npm": ">=6.0.0"
45
45
  },
46
- "gitHead": "2c0bcabdf0bf2a269fed50c6f23ba777845968a0"
46
+ "gitHead": "a9e55435c489f3379d88565bf3f729deb29bfb45"
47
47
  }