This commit is contained in:
Pierre Noël 2021-03-11 10:41:35 +01:00
parent 54512c6049
commit b39aa3c8bd
9 changed files with 207 additions and 108 deletions

View File

@ -1,10 +1,9 @@
'use strict';
const { cloneDeep } = require('lodash/fp');
const { before } = require('../field');
const { after } = require('../field');
describe('i18n - Migration - disable localization on a field', () => {
describe('before', () => {
describe('after', () => {
describe('Should not migrate', () => {
test("Doesn't migrate if model isn't localized", async () => {
const find = jest.fn();
@ -12,133 +11,144 @@ describe('i18n - Migration - disable localization on a field', () => {
query: () => {
find;
},
};
const model = {
collectionName: 'dogs',
info: { name: 'dog' },
attributes: {
name: { type: 'string' },
code: { type: 'string' },
plugins: {
i18n: {
services: {
'content-types': {
isLocalized: () => false,
},
},
},
},
};
const previousDefinition = {
collectionName: 'dogs',
info: { name: 'dog' },
attributes: {
name: { type: 'string' },
},
};
const model = {};
const previousDefinition = {};
await before({ model, definition: model, previousDefinition });
await after({ model, definition: model, previousDefinition });
expect(find).not.toHaveBeenCalled();
});
test("Doesn't migrate if no attribute changed (without i18n)", async () => {
const find = jest.fn();
const getLocalizedFields = jest
.fn()
.mockReturnValueOnce([])
.mockReturnValueOnce([]);
global.strapi = {
query: () => {
find;
},
};
const model = {
collectionName: 'dogs',
info: { name: 'dog' },
attributes: {
name: { type: 'string' },
code: { type: 'string' },
plugins: {
i18n: {
services: {
'content-types': {
isLocalized: () => true,
getLocalizedFields,
},
},
},
},
};
const previousDefinition = model;
const model = { attributes: { name: {} } };
const previousDefinition = { attributes: { name: {} } };
await before({ model, definition: model, previousDefinition });
await after({ model, definition: model, previousDefinition });
expect(getLocalizedFields).toHaveBeenCalledTimes(2);
expect(find).not.toHaveBeenCalled();
});
test("Doesn't migrate if no attribute changed (with i18n)", async () => {
const find = jest.fn();
const getLocalizedFields = jest
.fn()
.mockReturnValueOnce(['name'])
.mockReturnValueOnce(['name']);
global.strapi = {
query: () => {
find;
},
};
const model = {
collectionName: 'dogs',
info: { name: 'dog' },
pluginOptions: { i18n: { localized: true } },
attributes: {
name: {
type: 'string',
pluginOptions: { i18n: { localized: true } },
},
code: {
type: 'string',
pluginOptions: { i18n: { localized: false } },
plugins: {
i18n: {
services: {
'content-types': {
isLocalized: () => true,
getLocalizedFields,
},
},
},
},
};
const previousDefinition = model;
const model = { attributes: { name: {} } };
const previousDefinition = { attributes: { name: {} } };
await before({ model, definition: model, previousDefinition });
await after({ model, definition: model, previousDefinition });
expect(getLocalizedFields).toHaveBeenCalledTimes(2);
expect(find).not.toHaveBeenCalled();
});
test("Doesn't migrate if field not localized and pluginOptions removed", async () => {
test("Doesn't migrate if field become localized", async () => {
const find = jest.fn();
const getLocalizedFields = jest
.fn()
.mockReturnValueOnce(['name'])
.mockReturnValueOnce([]);
global.strapi = {
query: () => {
find;
},
};
const model = {
collectionName: 'dogs',
info: { name: 'dog' },
pluginOptions: { i18n: { localized: true } },
attributes: {
name: {
type: 'string',
pluginOptions: { i18n: { localized: false } },
plugins: {
i18n: {
services: {
'content-types': {
isLocalized: () => true,
getLocalizedFields,
},
},
},
},
};
const previousDefinition = cloneDeep(model);
delete previousDefinition.attributes.name.pluginOptions;
const model = { attributes: { name: {} } };
const previousDefinition = { attributes: { name: {} } };
await before({ model, definition: model, previousDefinition });
await after({ model, definition: model, previousDefinition });
expect(getLocalizedFields).toHaveBeenCalledTimes(2);
expect(find).not.toHaveBeenCalled();
});
test("Doesn't migrate if field becomes localized", async () => {
test("Doesn't migrate if field is deleted", async () => {
const find = jest.fn();
const getLocalizedFields = jest
.fn()
.mockReturnValueOnce([])
.mockReturnValueOnce(['name']);
global.strapi = {
query: () => {
find;
},
};
const model = {
collectionName: 'dogs',
info: { name: 'dog' },
pluginOptions: { i18n: { localized: true } },
attributes: {
name: {
type: 'string',
pluginOptions: { i18n: { localized: true } },
plugins: {
i18n: {
services: {
'content-types': {
isLocalized: () => true,
getLocalizedFields,
},
},
},
},
};
const previousDefinition = cloneDeep(model);
previousDefinition.attributes.name.pluginOptions.i18n.localized = false;
const model = { attributes: {} };
const previousDefinition = { attributes: { name: {} } };
await before({ model, definition: model, previousDefinition });
await after({ model, definition: model, previousDefinition });
expect(getLocalizedFields).toHaveBeenCalledTimes(2);
expect(find).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,53 @@
'use strict';
const { shouldBeProcessed, getUpdatesInfo } = require('../utils');
describe('i18n - migration utils', () => {
describe('shouldBeProcessed', () => {
const testData = [
[[], [], false],
[['en'], [], false],
[['en', 'fr'], [], false],
[['en', 'fr'], [{ locale: 'en' }], false],
[['en', 'fr'], [{ locale: 'fr' }], false],
[['en'], [{ locale: 'fr' }, { locale: 'en' }], false],
[['en', 'fr'], [{ locale: 'fr' }, { locale: 'en' }], false],
[[], [{ locale: 'en' }], true],
[['en'], [{ locale: 'fr' }], true],
[['en', 'fr'], [{ locale: 'it' }], true],
];
test.each(testData)('%p %j : %p', (processedLocaleCodes, localizations, expectedResult) => {
const result = shouldBeProcessed(processedLocaleCodes)({ localizations });
expect(result).toBe(expectedResult);
});
});
describe('getUpdatesInfo', () => {
const testData = [
[
[{ name: 'Name', nickname: 'Nickname', localizations: [{ id: 1 }, { id: 2 }] }],
['name'],
[{ entriesIdsToUpdate: [1, 2], attributesValues: { name: 'Name' } }],
],
[
[
{ name: 'Name 1', nickname: 'Nickname 1', localizations: [{ id: 1 }, { id: 2 }] },
{ name: 'Name 2', nickname: 'Nickname 2', localizations: [{ id: 3 }, { id: 4 }] },
],
['name'],
[
{ entriesIdsToUpdate: [1, 2], attributesValues: { name: 'Name 1' } },
{ entriesIdsToUpdate: [3, 4], attributesValues: { name: 'Name 2' } },
],
],
];
test.each(testData)('%j', (entriesToProcess, attributesToMigrate, expectedResult) => {
const result = getUpdatesInfo({ entriesToProcess, attributesToMigrate });
expect(result).toEqual(expectedResult);
});
});
});

View File

@ -32,6 +32,7 @@ const after = async ({ model, definition, previousDefinition, ORM }) => {
} else if (model.orm === 'mongoose') {
await migrateForMongoose({ model, attributesToMigrate, locales });
}
throw new Error('pouet');
};
const before = () => {};

View File

@ -1,6 +1,8 @@
'use strict';
const { shouldBeProcesseed, getUpdatesInfo } = require('./utils');
const { singular } = require('pluralize');
const { has, omit, pick } = require('lodash/fp');
const { shouldBeProcessed, getUpdatesInfo } = require('./utils');
const BATCH_SIZE = 1000;
@ -28,17 +30,16 @@ const batchUpdate = async (updatesInfo, trx, model) => {
const updateFromTmpTable = async ({ model, trx, attributesToMigrate }) => {
const collectionName = model.collectionName;
let bindings = [];
if (model.client === 'pg') {
const substitutes = attributesToMigrate.map(() => '?? = ??.??').join(',');
bindings.push(collectionName);
const bindings = [collectionName];
attributesToMigrate.forEach(attr => bindings.push(attr, TMP_TABLE_NAME, attr));
bindings.push(TMP_TABLE_NAME, collectionName, TMP_TABLE_NAME);
await trx.raw(`UPDATE ?? SET ${substitutes} FROM ?? WHERE ??.id = ??.id;`, bindings);
} else if (model.client === 'mysql') {
const substitutes = attributesToMigrate.map(() => '??.?? = ??.??').join(',');
bindings.push(collectionName, TMP_TABLE_NAME, collectionName, TMP_TABLE_NAME);
const bindings = [collectionName, TMP_TABLE_NAME, collectionName, TMP_TABLE_NAME];
attributesToMigrate.forEach(attr => bindings.push(collectionName, attr, TMP_TABLE_NAME, attr));
await trx.raw(`UPDATE ?? JOIN ?? ON ??.id = ??.id SET ${substitutes};`, bindings);
@ -60,6 +61,9 @@ const createTmpTable = async ({ ORM, attributesToMigrate, model }) => {
const deleteTmpTable = ({ ORM }) => ORM.knex.schema.dropTableIfExists(TMP_TABLE_NAME);
const migrateForBookshelf = async ({ ORM, model, attributesToMigrate, locales }) => {
const localizationAssoc = model.associations.find(a => a.alias === 'localizations');
const localizationTableName = localizationAssoc.tableCollectionName;
// The migration is custom for pg and mysql for better perfomance
const isPgOrMysql = ['pg', 'mysql'].includes(model.client);
@ -68,31 +72,61 @@ const migrateForBookshelf = async ({ ORM, model, attributesToMigrate, locales })
}
const trx = await ORM.knex.transaction();
const locsAttr = model.attributes.localizations;
const foreignKey = `${singular(model.collectionName)}_${model.primaryKey}`;
const relatedKey = `${locsAttr.attribute}_${locsAttr.column}`;
try {
const processedLocaleCodes = [];
for (const locale of locales) {
let offset = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const batch = await trx
.select([...attributesToMigrate, 'locale', 'localizations'])
.from(model.collectionName)
.where('locale', locale.code)
.orderBy('id')
.offset(offset)
.limit(BATCH_SIZE);
let batch = await trx
.select([
...attributesToMigrate.map(attr => `model.${attr}`),
'model.id as __strapi_rootId',
'relModel.id as id',
'relModel.locale',
])
.join(
`${localizationTableName} as loc`,
`model.${model.primaryKey}`,
'=',
`loc.${foreignKey}`
)
.join(
`${model.collectionName} as relModel`,
`loc.${relatedKey}`,
'=',
`relModel.${model.primaryKey}`
)
.from(
trx
.select('*')
.from(`${model.collectionName} as subModel`)
.orderBy(`subModel.${model.primaryKey}`)
.where(`subModel.locale`, locale.code)
.offset(offset)
.limit(BATCH_SIZE)
.as('model')
);
let entries = batch.reduce((entries, entry) => {
if (has(entry.__strapi_rootId, entries)) {
entries[entry.__strapi_rootId].localizations.push(pick(['id', 'locale'], entry));
} else {
entries[entry.__strapi_rootId] = omit(['id', 'locale', '__strapi_rootId'], entry);
entries[entry.__strapi_rootId].localizations = [pick(['id', 'locale'], entry)];
}
return entries;
}, {});
entries = Object.values(entries);
offset += BATCH_SIZE;
// postgres automatically parses JSON, but not sqlite nor mysql
batch.forEach(entry => {
if (typeof entry.localizations === 'string') {
entry.localizations = JSON.parse(entry.localizations);
}
});
const entriesToProcess = batch.filter(shouldBeProcesseed(processedLocaleCodes));
const updatesInfo = getUpdatesInfo({ entriesToProcess, locale, attributesToMigrate });
const entriesToProcess = batch.filter(shouldBeProcessed(processedLocaleCodes));
const updatesInfo = getUpdatesInfo({ entriesToProcess, attributesToMigrate });
if (isPgOrMysql) {
await batchInsertInTmpTable(updatesInfo, trx);
@ -100,7 +134,7 @@ const migrateForBookshelf = async ({ ORM, model, attributesToMigrate, locales })
await batchUpdate(updatesInfo, trx, model);
}
if (batch.length < BATCH_SIZE) {
if (entries.length < BATCH_SIZE) {
break;
}
}

View File

@ -1,6 +1,6 @@
'use strict';
const { shouldBeProcesseed, getUpdatesInfo } = require('./utils');
const { shouldBeProcessed, getUpdatesInfo } = require('./utils');
const BATCH_SIZE = 1000;
@ -17,6 +17,7 @@ const migrateForMongoose = async ({ model, attributesToMigrate, locales }) => {
const batch = await model
.find(findParams, [...attributesToMigrate, 'locale', 'localizations'])
.populate('localizations', 'locale id')
.sort({ _id: 1 })
.limit(BATCH_SIZE);
@ -25,9 +26,9 @@ const migrateForMongoose = async ({ model, attributesToMigrate, locales }) => {
}
batchCount = batch.length;
const entriesToProcess = batch.filter(shouldBeProcesseed);
const entriesToProcess = batch.filter(shouldBeProcessed(processedLocaleCodes));
const updatesInfo = getUpdatesInfo({ entriesToProcess, locale, attributesToMigrate });
const updatesInfo = getUpdatesInfo({ entriesToProcess, attributesToMigrate });
const updates = updatesInfo.map(({ entriesIdsToUpdate, attributesValues }) => ({
updateMany: { filter: { _id: { $in: entriesIdsToUpdate } }, update: attributesValues },
}));

View File

@ -2,26 +2,24 @@
const { pick, prop, intersection } = require('lodash/fp');
const shouldBeProcesseed = processedLocaleCodes => entry => {
const shouldBeProcessed = processedLocaleCodes => entry => {
return (
entry.localizations.length > 1 &&
entry.localizations.length > 0 &&
intersection(entry.localizations.map(prop('locale')), processedLocaleCodes).length === 0
);
};
const getUpdatesInfo = ({ entriesToProcess, locale, attributesToMigrate }) => {
const getUpdatesInfo = ({ entriesToProcess, attributesToMigrate }) => {
const updates = [];
for (const entry of entriesToProcess) {
const attributesValues = pick(attributesToMigrate, entry);
const entriesIdsToUpdate = entry.localizations
.filter(related => related.locale !== locale.code)
.map(prop('id'));
const entriesIdsToUpdate = entry.localizations.map(prop('id'));
updates.push({ entriesIdsToUpdate, attributesValues });
}
return updates;
};
module.exports = {
shouldBeProcesseed,
shouldBeProcessed,
getUpdatesInfo,
};

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"lodash": "4.17.20",
"pluralize": "8.0.0",
"strapi-utils": "3.5.3"
},
"author": {

View File

@ -341,14 +341,15 @@ class Strapi {
this.models['strapi_webhooks'] = webhookModel(this.config);
this.db = createDatabaseManager(this);
await this.runLifecyclesFunctions(LIFECYCLES.REGISTER);
await this.db.initialize();
this.store = createCoreStore({
environment: this.config.environment,
db: this.db,
});
await this.runLifecyclesFunctions(LIFECYCLES.REGISTER);
await this.db.initialize();
this.webhookStore = createWebhookStore({ db: this.db });
await this.startWebhooks();

View File

@ -15164,7 +15164,7 @@ please-upgrade-node@^3.2.0:
dependencies:
semver-compare "^1.0.0"
pluralize@^8.0.0:
pluralize@8.0.0, pluralize@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==