@strapi/database 4.6.0-beta.2 → 4.6.1

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,93 @@
1
+ 'use strict';
2
+
3
+ const { Database } = require('../index');
4
+
5
+ jest.mock('../connection', () =>
6
+ jest.fn(() => {
7
+ const trx = {
8
+ commit: jest.fn(),
9
+ rollback: jest.fn(),
10
+ };
11
+ return {
12
+ ...trx,
13
+ transaction: jest.fn(async () => trx),
14
+ };
15
+ })
16
+ );
17
+
18
+ jest.mock('../dialects', () => ({
19
+ getDialect: jest.fn(() => ({
20
+ configure: jest.fn(),
21
+ initialize: jest.fn(),
22
+ })),
23
+ }));
24
+
25
+ jest.mock('../migrations', () => ({
26
+ createMigrationsProvider: jest.fn(),
27
+ }));
28
+
29
+ const config = {
30
+ models: [
31
+ {
32
+ tableName: 'strapi_core_store_settings',
33
+ },
34
+ ],
35
+ connection: {
36
+ client: 'postgres',
37
+ connection: {
38
+ database: 'strapi',
39
+ user: 'strapi',
40
+ password: 'strapi',
41
+ port: 5432,
42
+ host: 'localhost',
43
+ },
44
+ },
45
+ };
46
+
47
+ describe('Database', () => {
48
+ describe('constructor', () => {
49
+ it('should throw an error if no config is provided', async () => {
50
+ expect(async () => Database.init()).rejects.toThrowError();
51
+ });
52
+
53
+ it('it should intialize if config is provided', async () => {
54
+ expect(() => Database.init(config)).toBeDefined();
55
+ });
56
+ });
57
+
58
+ describe('Transaction', () => {
59
+ it('transaction should be defined', async () => {
60
+ const db = await Database.init(config);
61
+ expect(db.transaction).toBeDefined();
62
+ });
63
+
64
+ it('should return value if transaction is complete', async () => {
65
+ const db = await Database.init(config);
66
+ const result = await db.transaction(async () => 'test');
67
+ expect(result).toBe('test');
68
+ expect(db.connection.commit).toHaveBeenCalledTimes(1);
69
+ });
70
+
71
+ it('rollback is called incase of error', async () => {
72
+ const db = await Database.init(config);
73
+ try {
74
+ await db.transaction(async () => {
75
+ throw new Error('test');
76
+ });
77
+ } catch {
78
+ /* ignore */
79
+ }
80
+ expect(db.connection.rollback).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('should throw error', async () => {
84
+ const db = await Database.init(config);
85
+
86
+ expect(async () => {
87
+ await db.transaction(async () => {
88
+ throw new Error('test');
89
+ });
90
+ }).rejects.toThrowError('test');
91
+ });
92
+ });
93
+ });
@@ -16,8 +16,16 @@ class PostgresDialect extends Dialect {
16
16
  }
17
17
 
18
18
  async initialize() {
19
- this.db.connection.client.driver.types.setTypeParser(1082, 'text', (v) => v); // Don't cast DATE string to Date()
20
- this.db.connection.client.driver.types.setTypeParser(1700, 'text', parseFloat);
19
+ this.db.connection.client.driver.types.setTypeParser(
20
+ this.db.connection.client.driver.types.builtins.DATE,
21
+ 'text',
22
+ (v) => v
23
+ ); // Don't cast DATE string to Date()
24
+ this.db.connection.client.driver.types.setTypeParser(
25
+ this.db.connection.client.driver.types.builtins.NUMERIC,
26
+ 'text',
27
+ parseFloat
28
+ );
21
29
  }
22
30
 
23
31
  usesForeignKeys() {
@@ -227,7 +227,7 @@ const createEntityManager = (db) => {
227
227
 
228
228
  const trx = await strapi.db.transaction();
229
229
  try {
230
- await this.attachRelations(uid, id, data, { transaction: trx });
230
+ await this.attachRelations(uid, id, data, { transaction: trx.get() });
231
231
 
232
232
  await trx.commit();
233
233
  } catch (e) {
@@ -311,7 +311,7 @@ const createEntityManager = (db) => {
311
311
 
312
312
  const trx = await strapi.db.transaction();
313
313
  try {
314
- await this.updateRelations(uid, id, data, { transaction: trx });
314
+ await this.updateRelations(uid, id, data, { transaction: trx.get() });
315
315
  await trx.commit();
316
316
  } catch (e) {
317
317
  await trx.rollback();
@@ -382,7 +382,7 @@ const createEntityManager = (db) => {
382
382
 
383
383
  const trx = await strapi.db.transaction();
384
384
  try {
385
- await this.deleteRelations(uid, id, { transaction: trx });
385
+ await this.deleteRelations(uid, id, { transaction: trx.get() });
386
386
 
387
387
  await trx.commit();
388
388
  } catch (e) {
@@ -198,38 +198,73 @@ 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 (here it's MySQL 5)
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
+
201
234
  switch (strapi.db.dialect.client) {
202
235
  case 'mysql':
203
- await cleanOrderColumnsForInnoDB({ id, attribute, db, inverseRelIds, transaction: trx });
236
+ // Here it's MariaDB and MySQL 8
237
+ await db
238
+ .getConnection()
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;
253
+ /*
254
+ UPDATE
255
+ :joinTable: as a,
256
+ (
257
+ SELECT
258
+ id,
259
+ ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order,
260
+ ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order
261
+ FROM :joinTable:
262
+ WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds)
263
+ ) AS b
264
+ SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order
265
+ WHERE b.id = a.id;
266
+ */
205
267
  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
268
  const joinTableName = addSchema(joinTable.name);
234
269
 
235
270
  // raw query as knex doesn't allow updating from a subquery
@@ -249,17 +284,17 @@ const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds, transaction
249
284
  .transacting(trx);
250
285
 
251
286
  /*
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`,
287
+ UPDATE :joinTable: as a
288
+ SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order
289
+ FROM (
290
+ SELECT
291
+ id,
292
+ ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order,
293
+ ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order
294
+ FROM :joinTable:
295
+ WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds)
296
+ ) AS b
297
+ WHERE b.id = a.id;
263
298
  */
264
299
  }
265
300
  }
@@ -267,9 +302,9 @@ const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds, transaction
267
302
 
268
303
  /*
269
304
  * 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
305
+ * The use of a session variable instead of a window function makes the query compatible with MySQL 5
271
306
  */
272
- const cleanOrderColumnsForInnoDB = async ({
307
+ const cleanOrderColumnsForOldDatabases = async ({
273
308
  id,
274
309
  attribute,
275
310
  db,
@@ -279,96 +314,68 @@ const cleanOrderColumnsForInnoDB = async ({
279
314
  const { joinTable } = attribute;
280
315
  const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable;
281
316
 
282
- const now = new Date().valueOf();
283
- const randomHex = randomBytes(16).toString('hex');
317
+ const randomSuffix = `${new Date().valueOf()}_${randomBytes(16).toString('hex')}`;
284
318
 
285
319
  if (hasOrderColumn(attribute) && id) {
286
- const tempOrderTableName = `orderTable_${now}_${randomHex}`;
287
- try {
288
- await db.connection
289
- .raw(
290
- `
291
- CREATE TABLE :tempOrderTableName:
292
- SELECT
293
- id,
294
- (
295
- SELECT count(*)
296
- FROM :joinTableName: b
297
- WHERE a.:orderColumnName: >= b.:orderColumnName: AND a.:joinColumnName: = b.:joinColumnName: AND a.:joinColumnName: = :id
298
- ) AS src_order
299
- FROM :joinTableName: a
300
- WHERE a.:joinColumnName: = :id
301
- `,
302
- {
303
- tempOrderTableName,
304
- joinTableName: joinTable.name,
305
- orderColumnName,
306
- joinColumnName: joinColumn.name,
307
- id,
308
- }
309
- )
310
- .transacting(trx);
311
-
312
- // raw query as knex doesn't allow updating from a subquery
313
- // https://github.com/knex/knex/issues/2504
314
- await db.connection
315
- .raw(
316
- `UPDATE ?? as a, (SELECT * FROM ??) AS b
317
- SET ?? = b.src_order
318
- WHERE a.id = b.id`,
319
- [joinTable.name, tempOrderTableName, orderColumnName]
320
- )
321
- .transacting(trx);
322
- } finally {
323
- await db.connection.raw(`DROP TABLE IF EXISTS ??`, [tempOrderTableName]).transacting(trx);
324
- }
320
+ // raw query as knex doesn't allow updating from a subquery
321
+ // https://github.com/knex/knex/issues/2504
322
+ const orderVar = `order_${randomSuffix}`;
323
+ await db.connection.raw(`SET @${orderVar} = 0;`).transacting(trx);
324
+ await db.connection
325
+ .raw(
326
+ `UPDATE :joinTableName: as a, (
327
+ SELECT id, (@${orderVar}:=@${orderVar} + 1) AS src_order
328
+ FROM :joinTableName:
329
+ WHERE :joinColumnName: = :id
330
+ ORDER BY :orderColumnName:
331
+ ) AS b
332
+ SET :orderColumnName: = b.src_order
333
+ WHERE a.id = b.id
334
+ AND a.:joinColumnName: = :id`,
335
+ {
336
+ joinTableName: joinTable.name,
337
+ orderColumnName,
338
+ joinColumnName: joinColumn.name,
339
+ id,
340
+ }
341
+ )
342
+ .transacting(trx);
325
343
  }
326
344
 
327
345
  if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) {
328
- const tempInvOrderTableName = `invOrderTable_${now}_${randomHex}`;
329
- try {
330
- await db.connection
331
- .raw(
332
- `
333
- CREATE TABLE ??
334
- SELECT
335
- id,
336
- (
337
- SELECT count(*)
338
- FROM ?? b
339
- WHERE a.?? >= b.?? AND a.?? = b.?? AND a.?? IN (${inverseRelIds
340
- .map(() => '?')
341
- .join(', ')})
342
- ) AS inv_order
343
- FROM ?? a
344
- WHERE a.?? IN (${inverseRelIds.map(() => '?').join(', ')})
345
- `,
346
- [
347
- tempInvOrderTableName,
348
- joinTable.name,
349
- inverseOrderColumnName,
350
- inverseOrderColumnName,
351
- inverseJoinColumn.name,
352
- inverseJoinColumn.name,
353
- inverseJoinColumn.name,
354
- ...inverseRelIds,
355
- joinTable.name,
356
- inverseJoinColumn.name,
357
- ...inverseRelIds,
358
- ]
359
- )
360
- .transacting(trx);
361
- await db.connection
362
- .raw(
363
- `UPDATE ?? as a, (SELECT * FROM ??) AS b
364
- SET ?? = b.inv_order
365
- WHERE a.id = b.id`,
366
- [joinTable.name, tempInvOrderTableName, inverseOrderColumnName]
367
- )
368
- .transacting(trx);
369
- } finally {
370
- await db.connection.raw(`DROP TABLE IF EXISTS ??`, [tempInvOrderTableName]).transacting(trx);
371
- }
346
+ const orderVar = `order_${randomSuffix}`;
347
+ const columnVar = `col_${randomSuffix}`;
348
+ await db.connection.raw(`SET @${orderVar} = 0;`).transacting(trx);
349
+ await db.connection
350
+ .raw(
351
+ `UPDATE ?? as a, (
352
+ SELECT
353
+ id,
354
+ @${orderVar}:=CASE WHEN @${columnVar} = ?? THEN @${orderVar} + 1 ELSE 1 END AS inv_order,
355
+ @${columnVar}:=?? ??
356
+ FROM ?? a
357
+ WHERE ?? IN(${inverseRelIds.map(() => '?').join(', ')})
358
+ ORDER BY ??, ??
359
+ ) AS b
360
+ SET ?? = b.inv_order
361
+ WHERE a.id = b.id
362
+ AND a.?? IN(${inverseRelIds.map(() => '?').join(', ')})`,
363
+ [
364
+ joinTable.name,
365
+ inverseJoinColumn.name,
366
+ inverseJoinColumn.name,
367
+ inverseJoinColumn.name,
368
+ joinTable.name,
369
+ inverseJoinColumn.name,
370
+ ...inverseRelIds,
371
+ inverseJoinColumn.name,
372
+ joinColumn.name,
373
+ inverseOrderColumnName,
374
+ inverseJoinColumn.name,
375
+ ...inverseRelIds,
376
+ ]
377
+ )
378
+ .transacting(trx);
372
379
  }
373
380
  };
374
381
 
package/lib/index.d.ts CHANGED
@@ -163,6 +163,15 @@ export interface Database {
163
163
  connection: Knex;
164
164
 
165
165
  query<T extends keyof AllTypes>(uid: T): QueryFromContentType<T>;
166
+ transaction(
167
+ cb?: (params: {
168
+ trx: Knex.Transaction;
169
+ rollback: () => Promise<void>;
170
+ commit: () => Promise<void>;
171
+ }) => Promise<unknown>
172
+ ):
173
+ | Promise<unknown>
174
+ | { get: () => Knex.Transaction; rollback: () => Promise<void>; commit: () => Promise<void> };
166
175
  }
167
176
  export class Database implements Database {
168
177
  static transformContentTypes(contentTypes: any[]): ModelConfig[];
package/lib/index.js CHANGED
@@ -8,9 +8,11 @@ const { createMigrationsProvider } = require('./migrations');
8
8
  const { createLifecyclesProvider } = require('./lifecycles');
9
9
  const createConnection = require('./connection');
10
10
  const errors = require('./errors');
11
+ const transactionCtx = require('./transaction-context');
11
12
 
12
13
  // TODO: move back into strapi
13
14
  const { transformContentTypes } = require('./utils/content-types');
15
+ const { validateDatabase } = require('./validations');
14
16
 
15
17
  class Database {
16
18
  constructor(config) {
@@ -49,6 +51,44 @@ class Database {
49
51
  return this.entityManager.getRepository(uid);
50
52
  }
51
53
 
54
+ async transaction(cb) {
55
+ const notNestedTransaction = !transactionCtx.get();
56
+ const trx = notNestedTransaction ? await this.connection.transaction() : transactionCtx.get();
57
+
58
+ async function commit() {
59
+ if (notNestedTransaction) {
60
+ await trx.commit();
61
+ }
62
+ }
63
+
64
+ async function rollback() {
65
+ if (notNestedTransaction) {
66
+ await trx.rollback();
67
+ }
68
+ }
69
+ if (!cb) {
70
+ return {
71
+ commit,
72
+ rollback,
73
+ get() {
74
+ return trx;
75
+ },
76
+ };
77
+ }
78
+
79
+ return transactionCtx.run(trx, async () => {
80
+ try {
81
+ const callbackParams = { trx, commit, rollback };
82
+ const res = await cb(callbackParams);
83
+ await commit();
84
+ return res;
85
+ } catch (error) {
86
+ await rollback();
87
+ throw error;
88
+ }
89
+ });
90
+ }
91
+
52
92
  getConnection(tableName) {
53
93
  const schema = this.connection.getSchemaName();
54
94
  const connection = tableName ? this.connection(tableName) : this.connection;
@@ -60,10 +100,6 @@ class Database {
60
100
  return schema ? trx.schema.withSchema(schema) : trx.schema;
61
101
  }
62
102
 
63
- transaction() {
64
- return this.connection.transaction();
65
- }
66
-
67
103
  queryBuilder(uid) {
68
104
  return this.entityManager.createQueryBuilder(uid);
69
105
  }
@@ -76,7 +112,11 @@ class Database {
76
112
 
77
113
  // TODO: move into strapi
78
114
  Database.transformContentTypes = transformContentTypes;
79
- Database.init = async (config) => new Database(config);
115
+ Database.init = async (config) => {
116
+ const db = new Database(config);
117
+ await validateDatabase(db);
118
+ return db;
119
+ };
80
120
 
81
121
  module.exports = {
82
122
  Database,
@@ -16,6 +16,8 @@ const isAnyToMany = (attribute) => ['oneToMany', 'manyToMany'].includes(attribut
16
16
  const isBidirectional = (attribute) => hasInversedBy(attribute) || hasMappedBy(attribute);
17
17
  const isOwner = (attribute) => !isBidirectional(attribute) || hasInversedBy(attribute);
18
18
  const shouldUseJoinTable = (attribute) => attribute.useJoinTable !== false;
19
+ const getJoinTableName = (tableName, attributeName) =>
20
+ _.snakeCase(`${tableName}_${attributeName}_links`);
19
21
 
20
22
  /**
21
23
  * Creates a oneToOne relation metadata
@@ -397,7 +399,7 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => {
397
399
  throw new Error(`Unknown target ${attribute.target}`);
398
400
  }
399
401
 
400
- const joinTableName = _.snakeCase(`${meta.tableName}_${attributeName}_links`);
402
+ const joinTableName = getJoinTableName(meta.tableName, attributeName);
401
403
 
402
404
  const joinColumnName = _.snakeCase(`${meta.singularName}_id`);
403
405
  let inverseJoinColumnName = _.snakeCase(`${targetMeta.singularName}_id`);
@@ -560,4 +562,5 @@ module.exports = {
560
562
  isAnyToMany,
561
563
  hasOrderColumn,
562
564
  hasInverseOrderColumn,
565
+ getJoinTableName,
563
566
  };
@@ -25,6 +25,8 @@ const OPERATORS = [
25
25
  '$between',
26
26
  '$startsWith',
27
27
  '$endsWith',
28
+ '$startsWithi',
29
+ '$endsWithi',
28
30
  '$contains',
29
31
  '$notContains',
30
32
  '$containsi',
@@ -283,10 +285,18 @@ const applyOperator = (qb, column, operator, value) => {
283
285
  qb.where(column, 'like', `${value}%`);
284
286
  break;
285
287
  }
288
+ case '$startsWithi': {
289
+ qb.whereRaw(`${fieldLowerFn(qb)} LIKE LOWER(?)`, [column, `${value}%`]);
290
+ break;
291
+ }
286
292
  case '$endsWith': {
287
293
  qb.where(column, 'like', `%${value}`);
288
294
  break;
289
295
  }
296
+ case '$endsWithi': {
297
+ qb.whereRaw(`${fieldLowerFn(qb)} LIKE LOWER(?)`, [column, `%${value}`]);
298
+ break;
299
+ }
290
300
  case '$contains': {
291
301
  qb.where(column, 'like', `%${value}%`);
292
302
  break;
@@ -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.getSchemaConnection().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.2",
3
+ "version": "4.6.1",
4
4
  "description": "Strapi's database layer",
5
5
  "homepage": "https://strapi.io",
6
6
  "bugs": {
@@ -34,7 +34,7 @@
34
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": "b852090f931cd21868c4016f24db2f9fdfc7a7ab"
46
+ "gitHead": "17a7845e3d453ea2e7911bda6ec25ed196dd5f16"
47
47
  }