Delete all entries in a locale when locale is deleted

This commit is contained in:
Pierre Noël 2021-04-01 11:58:17 +02:00
parent 6b6e3eba55
commit 3c85f38fb4
6 changed files with 173 additions and 17 deletions

View File

@ -11,6 +11,8 @@ const { contentTypes: contentTypesUtils } = require('strapi-utils');
const { singular } = require('pluralize'); const { singular } = require('pluralize');
const { handleDatabaseError } = require('./utils/errors'); const { handleDatabaseError } = require('./utils/errors');
const BATCH_SIZE = 1000;
const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants; const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants;
const pickCountFilters = omit(['sort', 'limit', 'start']); const pickCountFilters = omit(['sort', 'limit', 'start']);
@ -173,7 +175,10 @@ module.exports = function createQueryBuilder({ model, strapi }) {
return wrapTransaction(runDelete, { transacting }); return wrapTransaction(runDelete, { transacting });
} }
async function deleteMany(params, { transacting } = {}) { async function deleteMany(
params,
{ transacting, returning = true, batchSize = BATCH_SIZE } = {}
) {
if (params[model.primaryKey]) { if (params[model.primaryKey]) {
const entries = await find({ ...params, _limit: 1 }, null, { transacting }); const entries = await find({ ...params, _limit: 1 }, null, { transacting });
if (entries.length > 0) { if (entries.length > 0) {
@ -182,12 +187,30 @@ module.exports = function createQueryBuilder({ model, strapi }) {
return null; return null;
} }
const paramsWithDefaults = _.defaults(params, { _limit: -1 }); if (returning) {
const entries = await find(paramsWithDefaults, null, { transacting }); const paramsWithDefaults = _.defaults(params, { _limit: -1 });
return pmap(entries, entry => deleteOne(entry.id, { transacting }), { const entries = await find(paramsWithDefaults, null, { transacting });
concurrency: 100, return pmap(entries, entry => deleteOne(entry.id, { transacting }), {
stopOnError: true, concurrency: 100,
}); stopOnError: true,
});
}
// returning false, we can optimize the function
const batchParams = _.assign({}, params, { _limit: batchSize, _sort: 'id:ASC' });
// eslint-disable-next-line no-constant-condition
while (true) {
const batch = await find(batchParams, null, { transacting });
await pmap(batch, entry => deleteOne(entry.id, { transacting }), {
concurrency: 100,
stopOnError: true,
});
if (batch.length < BATCH_SIZE) {
break;
}
}
} }
function search(params, populate) { function search(params, populate) {

View File

@ -5,12 +5,15 @@
const _ = require('lodash'); const _ = require('lodash');
const { prop, omit } = require('lodash/fp'); const { prop, omit } = require('lodash/fp');
const pmap = require('p-map');
const { convertRestQueryParams, buildQuery } = require('strapi-utils'); const { convertRestQueryParams, buildQuery } = require('strapi-utils');
const { contentTypes: contentTypesUtils } = require('strapi-utils'); const { contentTypes: contentTypesUtils } = require('strapi-utils');
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const populateQueries = require('./utils/populate-queries'); const populateQueries = require('./utils/populate-queries');
const BATCH_SIZE = 1000;
const { PUBLISHED_AT_ATTRIBUTE, DP_PUB_STATES } = contentTypesUtils.constants; const { PUBLISHED_AT_ATTRIBUTE, DP_PUB_STATES } = contentTypesUtils.constants;
const { findComponentByGlobalId } = require('./utils/helpers'); const { findComponentByGlobalId } = require('./utils/helpers');
const { handleDatabaseError } = require('./utils/errors'); const { handleDatabaseError } = require('./utils/errors');
@ -505,7 +508,10 @@ module.exports = ({ model, strapi }) => {
return model.updateRelations(Object.assign(params, { values: relations }), { session }); return model.updateRelations(Object.assign(params, { values: relations }), { session });
} }
async function deleteMany(params, { session = null } = {}) { async function deleteMany(
params,
{ session = null, returning = true, batchSize = BATCH_SIZE } = {}
) {
if (params[model.primaryKey]) { if (params[model.primaryKey]) {
const entries = await find({ ...params, _limit: 1 }, null, { session }); const entries = await find({ ...params, _limit: 1 }, null, { session });
if (entries.length > 0) { if (entries.length > 0) {
@ -514,8 +520,28 @@ module.exports = ({ model, strapi }) => {
return null; return null;
} }
const entries = await find(params, null, { session }); if (returning) {
return Promise.all(entries.map(entry => deleteOne(entry[model.primaryKey], { session }))); const entries = await find(params, null, { session });
return pmap(entries, entry => deleteOne(entry[model.primaryKey], { session }), {
concurrency: 100,
stopOnError: true,
});
}
// returning false, we can optimize the function
const batchParams = _.assign({}, params, { _limit: batchSize, _sort: 'id:ASC' });
// eslint-disable-next-line no-constant-condition
while (true) {
const batch = await find(batchParams, null, { session });
await pmap(batch, entry => deleteOne(entry[model.primaryKey], { session }), {
concurrency: 100,
stopOnError: true,
});
if (batch.length < BATCH_SIZE) {
break;
}
}
} }
async function deleteOne(id, { session = null } = {}) { async function deleteOne(id, { session = null } = {}) {

View File

@ -19,6 +19,7 @@
"mongoose": "5.10.8", "mongoose": "5.10.8",
"mongoose-float": "^1.0.4", "mongoose-float": "^1.0.4",
"mongoose-long": "^0.3.2", "mongoose-long": "^0.3.2",
"p-map": "4.0.0",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"semver": "^7.3.4", "semver": "^7.3.4",
"strapi-utils": "3.5.4" "strapi-utils": "3.5.4"

View File

@ -122,14 +122,35 @@ describe('Locales', () => {
test('delete', async () => { test('delete', async () => {
const locale = { name: 'French', code: 'fr' }; const locale = { name: 'French', code: 'fr' };
const deleteFn = jest.fn(() => locale); const deleteFn = jest.fn(() => locale);
const query = jest.fn(() => ({ delete: deleteFn })); const findOne = jest.fn(() => locale);
global.strapi = { query }; const isLocalized = jest.fn(() => true);
const query = jest.fn(() => ({ delete: deleteFn, findOne }));
global.strapi = {
query,
plugins: { i18n: { services: { 'content-types': { isLocalized } } } },
contentTypes: { 'application::country.country': {} },
};
const deletedLocale = await localesService.delete({ id: 1 }); const deletedLocale = await localesService.delete({ id: 1 });
expect(query).toHaveBeenCalledWith('locale', 'i18n'); expect(query).toHaveBeenCalledWith('locale', 'i18n');
expect(deleteFn).toHaveBeenCalledWith({ id: 1 }); expect(deleteFn).toHaveBeenCalledWith({ id: 1 });
expect(deletedLocale).toMatchObject(locale); expect(deletedLocale).toMatchObject(locale);
}); });
test('delete - not found', async () => {
const locale = { name: 'French', code: 'fr' };
const deleteFn = jest.fn(() => locale);
const findOne = jest.fn(() => undefined);
const query = jest.fn(() => ({ delete: deleteFn, findOne }));
global.strapi = {
query,
};
const deletedLocale = await localesService.delete({ id: 1 });
expect(query).toHaveBeenCalledWith('locale', 'i18n');
expect(deleteFn).not.toHaveBeenCalled();
expect(deletedLocale).toBeUndefined();
});
}); });
describe('initDefaultLocale', () => { describe('initDefaultLocale', () => {

View File

@ -2,6 +2,7 @@
const { isNil } = require('lodash/fp'); const { isNil } = require('lodash/fp');
const { DEFAULT_LOCALE } = require('../constants'); const { DEFAULT_LOCALE } = require('../constants');
const { getService } = require('../utils');
const { getCoreStore } = require('../utils'); const { getCoreStore } = require('../utils');
@ -15,7 +16,15 @@ const create = locale => strapi.query('locale', 'i18n').create(locale);
const update = (params, updates) => strapi.query('locale', 'i18n').update(params, updates); const update = (params, updates) => strapi.query('locale', 'i18n').update(params, updates);
const deleteFn = ({ id }) => strapi.query('locale', 'i18n').delete({ id }); const deleteFn = async ({ id }) => {
let localeToDelete = await strapi.query('locale', 'i18n').findOne({ id });
if (localeToDelete) {
await deleteAllLocalizedEntriesFor({ locale: localeToDelete.code });
localeToDelete = await strapi.query('locale', 'i18n').delete({ id });
}
return localeToDelete;
};
const setDefaultLocale = ({ code }) => getCoreStore().set({ key: 'default_locale', value: code }); const setDefaultLocale = ({ code }) => getCoreStore().set({ key: 'default_locale', value: code });
@ -44,6 +53,16 @@ const initDefaultLocale = async () => {
} }
}; };
const deleteAllLocalizedEntriesFor = async ({ locale }) => {
const { isLocalized } = getService('content-types');
const localizedModels = Object.values(strapi.contentTypes).filter(isLocalized);
for (const model of localizedModels) {
await strapi.query(model.uid).delete({ locale }, { returning: false });
}
};
module.exports = { module.exports = {
find, find,
findById, findById,

View File

@ -2,6 +2,7 @@
const { omit } = require('lodash/fp'); const { omit } = require('lodash/fp');
const { createTestBuilder } = require('../../../test/helpers/builder');
const { createStrapiInstance } = require('../../../test/helpers/strapi'); const { createStrapiInstance } = require('../../../test/helpers/strapi');
const { createAuthRequest } = require('../../../test/helpers/request'); const { createAuthRequest } = require('../../../test/helpers/request');
@ -13,11 +14,31 @@ const data = {
const omitTimestamps = omit(['updatedAt', 'createdAt', 'updated_at', 'created_at']); const omitTimestamps = omit(['updatedAt', 'createdAt', 'updated_at', 'created_at']);
const compareLocales = (a, b) => (a.code < b.code ? -1 : 1); const compareLocales = (a, b) => (a.code < b.code ? -1 : 1);
const productModel = {
pluginOptions: {
i18n: {
localized: true,
},
},
attributes: {
name: {
type: 'string',
},
},
connection: 'default',
name: 'product',
description: '',
collectionName: '',
};
describe('CRUD locales', () => { describe('CRUD locales', () => {
let rq; let rq;
let strapi; let strapi;
const builder = createTestBuilder();
beforeAll(async () => { beforeAll(async () => {
await builder.addContentType(productModel).build();
strapi = await createStrapiInstance(); strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi }); rq = await createAuthRequest({ strapi });
}); });
@ -167,7 +188,9 @@ describe('CRUD locales', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toHaveLength(data.locales.length); expect(res.body).toHaveLength(data.locales.length);
expect(res.body.sort(compareLocales)).toMatchObject(data.locales.sort(compareLocales)); expect(res.body.sort(compareLocales)).toMatchObject(
data.locales.slice().sort(compareLocales)
);
}); });
}); });
@ -179,17 +202,17 @@ describe('CRUD locales', () => {
}; };
let res = await rq({ let res = await rq({
url: `/i18n/locales/${data.locales[0].id}`, url: `/i18n/locales/${data.locales[1].id}`,
method: 'PUT', method: 'PUT',
body: localeUpdate, body: localeUpdate,
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject({ expect(res.body).toMatchObject({
...omitTimestamps(data.locales[0]), ...omitTimestamps(data.locales[1]),
...localeUpdate, ...localeUpdate,
}); });
data.locales[0] = res.body; data.locales[1] = res.body;
}); });
test('Cannot update the code of a locale (without name)', async () => { test('Cannot update the code of a locale (without name)', async () => {
@ -306,11 +329,54 @@ describe('CRUD locales', () => {
}); });
test('Can delete a locale', async () => { test('Can delete a locale', async () => {
const { body: frenchProduct } = await rq({
url: '/content-manager/collection-types/application::product.product',
method: 'POST',
qs: { plugins: { i18n: { locale: 'fr' } } },
body: { name: 'product name' },
});
await rq({
url: '/content-manager/collection-types/application::product.product',
method: 'POST',
qs: { plugins: { i18n: { locale: 'en', relatedEntityId: frenchProduct.id } } },
body: { name: 'product name' },
});
const {
body: { results: createdProducts },
} = await rq({
url: '/content-manager/collection-types/application::product.product',
method: 'GET',
qs: { _locale: 'fr' },
});
expect(createdProducts).toHaveLength(1);
expect(createdProducts[0].localizations[0].locale).toBe('en');
const res = await rq({ const res = await rq({
url: `/i18n/locales/${data.locales[1].id}`, url: `/i18n/locales/${data.locales[1].id}`,
method: 'DELETE', method: 'DELETE',
}); });
const {
body: { results: frenchProducts },
} = await rq({
url: '/content-manager/collection-types/application::product.product',
method: 'GET',
qs: { _locale: 'fr' },
});
expect(frenchProducts).toHaveLength(0);
const {
body: { results: englishProducts },
} = await rq({
url: '/content-manager/collection-types/application::product.product',
method: 'GET',
qs: { _locale: 'en' },
});
expect(englishProducts).toHaveLength(1);
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject(omitTimestamps(data.locales[1])); expect(res.body).toMatchObject(omitTimestamps(data.locales[1]));
data.deletedLocales.push(res.body); data.deletedLocales.push(res.body);