diff --git a/packages/strapi-connector-bookshelf/lib/build-database-schema.js b/packages/strapi-connector-bookshelf/lib/build-database-schema.js index 31de44eaf3..9f727713ce 100644 --- a/packages/strapi-connector-bookshelf/lib/build-database-schema.js +++ b/packages/strapi-connector-bookshelf/lib/build-database-schema.js @@ -398,7 +398,7 @@ const createOrUpdateTable = async ({ table, attributes, definition, ORM, model } module.exports = async ({ ORM, loadedModel, definition, connection, model }) => { // run migrations - await strapi.db.migrations.runMigration(migrateSchemas, { + await strapi.db.migrations.run(migrateSchemas, { ORM, loadedModel, definition, diff --git a/packages/strapi-connector-mongoose/lib/mount-models.js b/packages/strapi-connector-mongoose/lib/mount-models.js index 2be0cc1ee7..e75dfa6e07 100644 --- a/packages/strapi-connector-mongoose/lib/mount-models.js +++ b/packages/strapi-connector-mongoose/lib/mount-models.js @@ -301,7 +301,7 @@ module.exports = async ({ models, target }, ctx) => { const definitionDidChange = await didDefinitionChange(definition, instance); // run migrations - await strapi.db.migrations.runMigration(migrateSchema, { + await strapi.db.migrations.run(migrateSchema, { definition, model: modelInstance, ORM: instance, diff --git a/packages/strapi-database/lib/__tests__/lifecycle-manager.test.js b/packages/strapi-database/lib/__tests__/lifecycle-manager.test.js new file mode 100644 index 0000000000..a0c3d3291c --- /dev/null +++ b/packages/strapi-database/lib/__tests__/lifecycle-manager.test.js @@ -0,0 +1,54 @@ +'use strict'; + +const createLifecycleManager = require('../lifecycle-manager'); + +describe('Lifecycle Manager', () => { + test('Allows registering lifecycles', () => { + const manager = createLifecycleManager(); + + const lifecycle = {}; + manager.register(lifecycle); + + expect(manager.lifecycles).toEqual([lifecycle]); + }); + + test('Will run all the lifecycles if no model specified', async () => { + const lifecycleA = { + find: jest.fn(), + }; + + const lifecycleB = { + find: jest.fn(), + }; + + const manager = createLifecycleManager(); + + manager.register(lifecycleA).register(lifecycleB); + + await manager.run('find', { uid: 'test-uid' }); + + expect(lifecycleA.find).toHaveBeenCalled(); + expect(lifecycleB.find).toHaveBeenCalled(); + }); + + test('Will match on model if specified', async () => { + const lifecycleA = { + model: 'test-uid', + find: jest.fn(), + }; + + const lifecycleB = { + model: 'other-uid', + find: jest.fn(), + }; + + const manager = createLifecycleManager(); + + manager.register(lifecycleA).register(lifecycleB); + + await manager.run('find', { uid: 'test-uid' }); + + expect(lifecycleA.find).toHaveBeenCalled(); + expect(lifecycleB.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/strapi-database/lib/database-manager.js b/packages/strapi-database/lib/database-manager.js index 204c92b0b0..f610c22374 100644 --- a/packages/strapi-database/lib/database-manager.js +++ b/packages/strapi-database/lib/database-manager.js @@ -7,6 +7,7 @@ const createConnectorRegistry = require('./connector-registry'); const constants = require('./constants'); const { validateModelSchemas } = require('./validation'); const createMigrationManager = require('./migration-manager'); +const createLifecycleManager = require('./lifecycle-manager'); class DatabaseManager { constructor(strapi) { @@ -23,6 +24,7 @@ class DatabaseManager { this.models = new Map(); this.migrations = createMigrationManager(this); + this.lifecycles = createLifecycleManager(); } async initialize() { diff --git a/packages/strapi-database/lib/lifecycle-manager.js b/packages/strapi-database/lib/lifecycle-manager.js new file mode 100644 index 0000000000..1c7d9540a2 --- /dev/null +++ b/packages/strapi-database/lib/lifecycle-manager.js @@ -0,0 +1,33 @@ +'use strict'; +const debug = require('debug')('strapi-database:lifecycle'); +const { isFunction, isNil } = require('lodash/fp'); + +class LifecycleManager { + constructor() { + debug('Initialize lifecycle manager'); + this.lifecycles = []; + } + + register(lifecycle) { + debug('Register lifecycle'); + + this.lifecycles.push(lifecycle); + return this; + } + + async run(action, model, ...args) { + for (const lifecycle of this.lifecycles) { + if (!isNil(lifecycle.model) && lifecycle.model !== model.uid) { + continue; + } + + if (isFunction(lifecycle[action])) { + debug(`Run lifecycle ${action} for model ${model.uid}`); + + await lifecycle[action](...args); + } + } + } +} + +module.exports = () => new LifecycleManager(); diff --git a/packages/strapi-database/lib/migration-manager.js b/packages/strapi-database/lib/migration-manager.js index a1d1f49970..8a29cbf39f 100644 --- a/packages/strapi-database/lib/migration-manager.js +++ b/packages/strapi-database/lib/migration-manager.js @@ -14,7 +14,7 @@ class MigrationManager { this.migrations.push(migration); } - async runMigration(fn, options, context = {}) { + async run(fn, options, context = {}) { debug('Run migration'); await this.runBefore(options, context); await fn(options, context); diff --git a/packages/strapi-database/lib/queries/__tests__/create-query.test.js b/packages/strapi-database/lib/queries/__tests__/create-query.test.js index 033189021b..0ea7cd5db0 100644 --- a/packages/strapi-database/lib/queries/__tests__/create-query.test.js +++ b/packages/strapi-database/lib/queries/__tests__/create-query.test.js @@ -4,6 +4,14 @@ const _ = require('lodash'); const createQuery = require('../create-query'); describe('Database queries', () => { + global.strapi = { + db: { + lifecycles: { + run() {}, + }, + }, + }; + describe('Substitute id with primaryKey in parameters', () => { test.each(['create', 'update', 'delete', 'find', 'findOne', 'search', 'count', 'countSearch'])( 'Calling "%s" replaces id by the primaryKey in the params of the model before calling the underlying connector', diff --git a/packages/strapi-database/lib/utils/lifecycles.js b/packages/strapi-database/lib/utils/lifecycles.js index 5875fd89f8..155a67711d 100644 --- a/packages/strapi-database/lib/utils/lifecycles.js +++ b/packages/strapi-database/lib/utils/lifecycles.js @@ -3,6 +3,10 @@ const _ = require('lodash'); const executeLifecycle = async (lifecycle, model, ...args) => { + // Run registered lifecycles + await strapi.db.lifecycles.run(lifecycle, model, ...args); + + // Run user lifecycles if (_.has(model, `lifecycles.${lifecycle}`)) { await model.lifecycles[lifecycle](...args); } diff --git a/packages/strapi-plugin-i18n/config/functions/bootstrap.js b/packages/strapi-plugin-i18n/config/functions/bootstrap.js index 66f273810c..e5e4595a50 100644 --- a/packages/strapi-plugin-i18n/config/functions/bootstrap.js +++ b/packages/strapi-plugin-i18n/config/functions/bootstrap.js @@ -1,6 +1,5 @@ 'use strict'; -const _ = require('lodash'); const { capitalize } = require('lodash/fp'); const { getService } = require('../../utils'); @@ -26,23 +25,23 @@ module.exports = async () => { await getService('locales').setDefaultLocale(DEFAULT_LOCALE); } - Object.values(strapi.models).forEach(model => { - if (getService('content-types').isLocalized(model)) { - // TODO: support adding lifecycles programmatically or connecting to a database event handler to avoid conflicts with existing lifecycles fonctions - - _.set(model, 'lifecycles.beforeCreate', async data => { - if (!data.locale) { - data.locale = await getService('locales').getDefaultLocale(); - } + Object.values(strapi.models) + .filter(model => getService('content-types').isLocalized(model)) + .forEach(model => { + strapi.db.lifecycles.register({ + model: model.uid, + async beforeCreate(data) { + await getService('localizations').assignDefaultLocale(data); + }, + async afterCreate(entry) { + await getService('localizations').addLocalizations(entry, { model }); + }, + async afterUpdate(entry) { + await getService('localizations').updateNonLocalizedFields(entry, { model }); + }, + async afterDelete(entry) { + await getService('localizations').removeEntryFromRelatedLocalizations(entry, { model }); + }, }); - - _.set(model, 'lifecycles.afterCreate', async entry => { - await getService('localizations').addLocalizations(entry, { model }); - }); - - _.set(model, 'lifecycles.afterUpdate', async entry => { - await getService('localizations').updateNonLocalizedFields(entry, { model }); - }); - } - }); + }); }; diff --git a/packages/strapi-plugin-i18n/services/__tests__/localizations.test.js b/packages/strapi-plugin-i18n/services/__tests__/localizations.test.js index 97f119eb1e..7051142fa9 100644 --- a/packages/strapi-plugin-i18n/services/__tests__/localizations.test.js +++ b/packages/strapi-plugin-i18n/services/__tests__/localizations.test.js @@ -1,6 +1,11 @@ 'use strict'; -const { addLocalizations, updateNonLocalizedFields } = require('../localizations'); +const { + assignDefaultLocale, + addLocalizations, + updateNonLocalizedFields, + removeEntryFromRelatedLocalizations, +} = require('../localizations'); const model = { uid: 'test-model', @@ -20,12 +25,43 @@ const model = { }; describe('localizations service', () => { + describe('assignDefaultLocale', () => { + test('Does not change the input if locale is already defined', async () => { + const input = { locale: 'myLocale' }; + await assignDefaultLocale(input); + + expect(input).toStrictEqual({ locale: 'myLocale' }); + }); + + test('Use default locale to set the locale on the input data', async () => { + const getDefaultLocaleMock = jest.fn(() => 'defaultLocale'); + + global.strapi = { + plugins: { + i18n: { + services: { + locales: { + getDefaultLocale: getDefaultLocaleMock, + }, + }, + }, + }, + }; + + const input = {}; + await assignDefaultLocale(input); + + expect(input).toStrictEqual({ locale: 'defaultLocale' }); + expect(getDefaultLocaleMock).toHaveBeenCalled(); + }); + }); + describe('addLocalizations', () => { test('Does nothing if entry already as a localizations array', async () => { const entry = { localizations: [] }; await addLocalizations(entry, { model }); - expect(entry).toEqual({ localizations: [] }); + expect(entry).toStrictEqual({ localizations: [] }); }); test('Updates entry in db', async () => { @@ -58,7 +94,7 @@ describe('localizations service', () => { await addLocalizations(entry, { model }); - expect(entry).toEqual({ + expect(entry).toStrictEqual({ id: entry.id, locale: entry.locale, localizations: [ @@ -127,4 +163,47 @@ describe('localizations service', () => { expect(update).toHaveBeenCalledWith({ id: 2 }, { stars: 1 }); }); }); + + describe('removeEntryFromRelatedLocalizations', () => { + test('Does nothing if no localizations set', async () => { + const update = jest.fn(); + global.strapi = { + query() { + return { update }; + }, + }; + + const entry = { id: 1, locale: 'test' }; + + await removeEntryFromRelatedLocalizations(entry, { model }); + + expect(update).not.toHaveBeenCalled(); + }); + + test('Removes entry from localizations', async () => { + const update = jest.fn(); + global.strapi = { + query() { + return { update }; + }, + }; + + const entry = { + id: 1, + locale: 'mainLocale', + localizations: [ + { id: 1, locale: 'mainLocale' }, + { id: 2, locale: 'otherLocale' }, + ], + }; + + await removeEntryFromRelatedLocalizations(entry, { model }); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + { id: 2 }, + { localizations: [{ id: 2, locale: 'otherLocale' }] } + ); + }); + }); }); diff --git a/packages/strapi-plugin-i18n/services/localizations.js b/packages/strapi-plugin-i18n/services/localizations.js index 87d22257a3..052caa0232 100644 --- a/packages/strapi-plugin-i18n/services/localizations.js +++ b/packages/strapi-plugin-i18n/services/localizations.js @@ -1,8 +1,26 @@ 'use strict'; const { pick, isNil } = require('lodash/fp'); + +const { getService } = require('../utils'); const { getNonLocalizedFields } = require('./content-types'); +/** + * Adds the default locale to an object if it isn't defined yet + * @param {Object} data a data object before being persisted into db + */ +const assignDefaultLocale = async data => { + if (isNil(data.locale)) { + data.locale = await getService('locales').getDefaultLocale(); + } +}; + +/** + * Create default localizations for an entry if it isn't defined yet + * @param {Object} entry entry to update + * @param {Object} options + * @param {Object} options.model corresponding model + */ const addLocalizations = async (entry, { model }) => { if (isNil(entry.localizations)) { const localizations = [{ locale: entry.locale, id: entry.id }]; @@ -12,10 +30,16 @@ const addLocalizations = async (entry, { model }) => { } }; +/** + * Update non localized fields of all the related localizations of an entry with the entry values + * @param {Object} entry entry to update + * @param {Object} options + * @param {Object} options.model corresponding model + */ const updateNonLocalizedFields = async (entry, { model }) => { - const fieldsToUpdate = pick(getNonLocalizedFields(model), entry); - if (Array.isArray(entry.localizations)) { + const fieldsToUpdate = pick(getNonLocalizedFields(model), entry); + const updateQueries = entry.localizations .filter(({ id }) => id != entry.id) .map(({ id }) => strapi.query(model.uid).update({ id }, fieldsToUpdate)); @@ -24,8 +48,28 @@ const updateNonLocalizedFields = async (entry, { model }) => { } }; +/** + * Remove entry from localizations & udpate related localizations + * This method should be used only after an entry is deleted + * @param {Object} entry entry to remove from localizations + * @param {Object} options + * @param {Object} options.model corresponding model + */ +const removeEntryFromRelatedLocalizations = async (entry, { model }) => { + if (Array.isArray(entry.localizations)) { + const newLocalizations = entry.localizations.filter(({ id }) => id != entry.id); + + const updateQueries = newLocalizations.map(({ id }) => { + return strapi.query(model.uid).update({ id }, { localizations: newLocalizations }); + }); + + await Promise.all(updateQueries); + } +}; + module.exports = { + assignDefaultLocale, addLocalizations, updateNonLocalizedFields, - getNonLocalizedFields, + removeEntryFromRelatedLocalizations, };