diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/attributes/advancedForm.js b/packages/core/content-type-builder/admin/src/components/FormModal/attributes/advancedForm.js index a1144b448c..5c69f5e4b9 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/attributes/advancedForm.js +++ b/packages/core/content-type-builder/admin/src/components/FormModal/attributes/advancedForm.js @@ -46,7 +46,7 @@ const advancedForm = { id: getTrad('form.attribute.item.settings.name'), defaultMessage: 'Settings', }, - items: [options.required, options.unique, options.private], + items: [options.required, options.private], }, ], }; @@ -209,7 +209,7 @@ const advancedForm = { id: getTrad('form.attribute.item.settings.name'), defaultMessage: 'Settings', }, - items: [options.required, options.unique, options.private], + items: [options.required, options.private], }, ], }; @@ -222,7 +222,7 @@ const advancedForm = { id: getTrad('form.attribute.item.settings.name'), defaultMessage: 'Settings', }, - items: [options.required, options.unique, options.private], + items: [options.required, options.private], }, ], }; @@ -297,13 +297,7 @@ const advancedForm = { id: getTrad('form.attribute.item.settings.name'), defaultMessage: 'Settings', }, - items: [ - options.required, - options.unique, - options.maxLength, - options.minLength, - options.private, - ], + items: [options.required, options.maxLength, options.minLength, options.private], }, ], }; @@ -330,13 +324,7 @@ const advancedForm = { id: getTrad('form.attribute.item.settings.name'), defaultMessage: 'Settings', }, - items: [ - options.required, - options.unique, - options.maxLength, - options.minLength, - options.private, - ], + items: [options.required, options.maxLength, options.minLength, options.private], }, ], }; diff --git a/packages/core/content-type-builder/server/controllers/validation/types.js b/packages/core/content-type-builder/server/controllers/validation/types.js index 0df1f33fda..038614fe91 100644 --- a/packages/core/content-type-builder/server/controllers/validation/types.js +++ b/packages/core/content-type-builder/server/controllers/validation/types.js @@ -51,7 +51,6 @@ const getTypeShape = (attribute, { modelType, attributes } = {}) => { return { multiple: yup.boolean(), required: validators.required, - unique: validators.unique, allowedTypes: yup .array() .of(yup.string().oneOf(['images', 'videos', 'files'])) @@ -129,7 +128,6 @@ const getTypeShape = (attribute, { modelType, attributes } = {}) => { return { default: yup.mixed().test(isValidDefaultJSON), required: validators.required, - unique: validators.unique, }; } case 'enumeration': { @@ -148,7 +146,6 @@ const getTypeShape = (attribute, { modelType, attributes } = {}) => { default: yup.string().when('enum', enumVal => yup.string().oneOf(enumVal)), enumName: yup.string().test(isValidName), required: validators.required, - unique: validators.unique, }; } case 'password': { @@ -225,7 +222,6 @@ const getTypeShape = (attribute, { modelType, attributes } = {}) => { return { default: yup.boolean(), required: validators.required, - unique: validators.unique, }; } diff --git a/packages/core/strapi/lib/services/entity-service/index.js b/packages/core/strapi/lib/services/entity-service/index.js index c86e733186..20a40c7c89 100644 --- a/packages/core/strapi/lib/services/entity-service/index.js +++ b/packages/core/strapi/lib/services/entity-service/index.js @@ -202,9 +202,14 @@ const createDefaultImplementation = ({ strapi, db, eventHub, entityValidator }) const isDraft = contentTypesUtils.isDraft(entityToUpdate, model); - const validData = await entityValidator.validateEntityUpdate(model, data, { - isDraft, - }); + const validData = await entityValidator.validateEntityUpdate( + model, + data, + { + isDraft, + }, + entityToUpdate + ); const query = transformParamsToQuery(uid, pickSelectionParams(wrappedParams)); diff --git a/packages/core/strapi/lib/services/entity-validator/__tests__/validators.test.js b/packages/core/strapi/lib/services/entity-validator/__tests__/validators.test.js new file mode 100644 index 0000000000..e772f4e19f --- /dev/null +++ b/packages/core/strapi/lib/services/entity-validator/__tests__/validators.test.js @@ -0,0 +1,1543 @@ +'use strict'; + +const strapiUtils = require('@strapi/utils'); +const { YupValidationError } = require('../../../../../utils/lib/errors'); +const entityValidator = require('../validators'); + +describe('Entity validator', () => { + const fakeFindOne = jest.fn(); + + global.strapi = { + db: { + query: jest.fn(() => ({ + findOne: fakeFindOne, + })), + }, + }; + + afterEach(() => { + jest.clearAllMocks(); + fakeFindOne.mockReset(); + }); + + describe('String unique validator', () => { + const fakeModel = { + kind: 'contentType', + modelName: 'test-model', + uid: 'test-uid', + privateAttributes: [], + options: {}, + attributes: { + attrStringUnique: { type: 'string', unique: true }, + }, + }; + + test('it does not validates the unique constraint if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.string( + { + attr: { type: 'string' }, + model: fakeModel, + updatedAttribute: { + name: 'attrStringUnique', + value: 'non-unique-test-data', + }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('non-unique-test-data'); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .string( + { + attr: { type: 'string', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrStringUnique', + value: null, + }, + entity: null, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.string( + { + attr: { type: 'string', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrStringUnique', + value: 'non-unique-test-data', + }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator('non-unique-test-data')).toBe('non-unique-test-data'); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrStringUnique: 'unique-test-data' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.string( + { + attr: { type: 'string', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrStringUnique', + value: 'unique-test-data', + }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator('unique-test-data'); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrStringUnique: 'non-updated-unique-test-data' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.string( + { + attr: { type: 'string', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrStringUnique', + value: 'non-updated-unique-test-data', + }, + entity: { id: 1, attrStringUnique: 'non-updated-unique-test-data' }, + }, + { isDraft: false } + ) + ); + + expect(await validator('non-updated-unique-test-data')).toBe('non-updated-unique-test-data'); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.string( + { + attr: { type: 'string', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrStringUnique', + value: 'test-data', + }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('test-data'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrStringUnique: 'test-data' }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.string( + { + attr: { type: 'string', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrStringUnique', + value: 'test-data', + }, + entity: { id: 1, attrStringUnique: 'other-data' }, + }, + { isDraft: false } + ) + ); + + await validator('test-data'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrStringUnique: 'test-data' }, { $not: { id: 1 } }] }, + }); + }); + }); + + describe('Integer unique validator', () => { + const fakeModel = { + kind: 'contentType', + uid: 'test-uid', + modelName: 'test-model', + privateAttributes: [], + options: {}, + attributes: { + attrIntegerUnique: { type: 'integer', unique: true }, + }, + }; + + test('it does not validates the unique constraint if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.integer( + { + attr: { type: 'integer' }, + model: fakeModel, + updatedAttribute: { name: 'attrIntegerUnique', value: 1 }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator(1); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .integer( + { + attr: { type: 'integer', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrIntegerUnique', value: null }, + entity: null, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.integer( + { + attr: { type: 'integer', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrIntegerUnique', value: 2 }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator(1)).toBe(1); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrIntegerUnique: 2 }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.integer( + { + attr: { type: 'integer', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrIntegerUnique', value: 2 }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator(2); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrIntegerUnique: 3 }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.integer( + { + attr: { type: 'integer', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrIntegerUnique', value: 3 }, + entity: { id: 1, attrIntegerUnique: 3 }, + }, + { isDraft: false } + ) + ); + + expect(await validator(3)).toBe(3); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.integer( + { + attr: { type: 'integer', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrIntegerUnique', value: 4 }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator(4); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrIntegerUnique: 4 }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.integer( + { + attr: { type: 'integer', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrIntegerUnique', value: 5 }, + entity: { id: 1, attrIntegerUnique: 42 }, + }, + { isDraft: false } + ) + ); + + await validator(5); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrIntegerUnique: 5 }, { $not: { id: 1 } }] }, + }); + }); + }); + + describe('BigInteger unique validator', () => { + const fakeModel = { + kind: 'contentType', + modelName: 'test-model', + uid: 'test-uid', + privateAttributes: [], + options: {}, + attributes: { + attrBigIntegerUnique: { type: 'biginteger', unique: true }, + }, + }; + + test('it does not validates the unique constraint if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.biginteger( + { + attr: { type: 'biginteger' }, + model: fakeModel, + updatedAttribute: { + name: 'attrBigIntegerUnique', + value: 1, + }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator(1); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .biginteger( + { + attr: { type: 'biginteger', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrBigIntegerUnique', + value: null, + }, + entity: null, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.biginteger( + { + attr: { type: 'biginteger', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrBigIntegerUnique', + value: 1, + }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator(1)).toBe(1); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrBigIntegerUnique: 2 }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.biginteger( + { + attr: { type: 'biginteger', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrBigIntegerUnique', + value: 2, + }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator(2); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrBigIntegerUnique: 3 }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.biginteger( + { + attr: { type: 'biginteger', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrBigIntegerUnique', + value: 3, + }, + entity: { id: 1, attrBigIntegerUnique: 3 }, + }, + { isDraft: false } + ) + ); + + expect(await validator(3)).toBe(3); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.biginteger( + { + attr: { type: 'biginteger', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrBigIntegerUnique', + value: 4, + }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator(4); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrBigIntegerUnique: 4 }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.biginteger( + { + attr: { type: 'biginteger', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrBigIntegerUnique', + value: 5, + }, + entity: { id: 1, attrBigIntegerUnique: 42 }, + }, + { isDraft: false } + ) + ); + + await validator(5); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrBigIntegerUnique: 5 }, { $not: { id: 1 } }] }, + }); + }); + }); + + describe('Float unique validator', () => { + const fakeModel = { + kind: 'contentType', + modelName: 'test-model', + uid: 'test-uid', + privateAttributes: [], + options: {}, + attributes: { + attrFloatUnique: { type: 'float', unique: true }, + }, + }; + + test('it does not validates the unique constraint if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.float( + { + attr: { type: 'float' }, + model: fakeModel, + updatedAttribute: { + name: 'attrFloatUnique', + value: 1, + }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator(1); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .float( + { + attr: { type: 'float', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrFloatUnique', + value: null, + }, + entity: null, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.float( + { + attr: { type: 'float', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrFloatUnique', + value: 1, + }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator(1)).toBe(1); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrFloatUnique: 2 }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.float( + { + attr: { type: 'float', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrFloatUnique', + value: 2, + }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator(2); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrFloatUnique: 3 }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.float( + { + attr: { type: 'float', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrFloatUnique', + value: 3, + }, + entity: { id: 1, attrFloatUnique: 3 }, + }, + { isDraft: false } + ) + ); + + expect(await validator(3)).toBe(3); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.float( + { + attr: { type: 'float', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrFloatUnique', + value: 4, + }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator(4); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrFloatUnique: 4 }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.float( + { + attr: { type: 'float', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrFloatUnique', + value: 5, + }, + entity: { id: 1, attrFloatUnique: 42 }, + }, + { isDraft: false } + ) + ); + + await validator(5); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrFloatUnique: 5 }, { $not: { id: 1 } }] }, + }); + }); + }); + + describe('UID unique validator', () => { + const fakeModel = { + kind: 'contentType', + modelName: 'test-model', + uid: 'test-uid', + privateAttributes: [], + options: {}, + attributes: { + attrUidUnique: { type: 'uid' }, + }, + }; + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.uid( + { + attr: { type: 'uid', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrUidUnique', value: 'non-unique-uid' }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator('non-unique-uid')).toBe('non-unique-uid'); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .uid( + { + attr: { type: 'uid', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrUidUnique', value: null }, + entity: null, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it always validates the unique constraint even if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.uid( + { + attr: { type: 'uid' }, + model: fakeModel, + updatedAttribute: { name: 'attrUidUnique', value: 'non-unique-uid' }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator('non-unique-uid')).toBe('non-unique-uid'); + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrUidUnique: 'non-unique-uid' }, + }); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrUidUnique: 'unique-uid' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.uid( + { + attr: { type: 'uid', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrUidUnique', value: 'unique-uid' }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator('unique-uid'); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrUidUnique: 'unchanged-unique-uid' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.uid( + { + attr: { type: 'uid', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrUidUnique', value: 'unchanged-unique-uid' }, + entity: { id: 1, attrUidUnique: 'unchanged-unique-uid' }, + }, + { isDraft: false } + ) + ); + + expect(await validator('unchanged-unique-uid')).toBe('unchanged-unique-uid'); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.uid( + { + attr: { type: 'uid', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrUidUnique', value: 'unique-uid' }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('unique-uid'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrUidUnique: 'unique-uid' }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.uid( + { + attr: { type: 'uid', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrUidUnique', value: 'unique-uid' }, + entity: { id: 1, attrUidUnique: 'other-uid' }, + }, + { isDraft: false } + ) + ); + + await validator('unique-uid'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrUidUnique: 'unique-uid' }, { $not: { id: 1 } }] }, + }); + }); + }); + + describe('Date unique validator', () => { + const fakeModel = { + kind: 'contentType', + modelName: 'test-model', + uid: 'test-uid', + privateAttributes: [], + options: {}, + attributes: { + attrDateUnique: { type: 'date', unique: true }, + }, + }; + + test('it does not validates the unique constraint if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.date( + { + attr: { type: 'date' }, + model: fakeModel, + updatedAttribute: { name: 'attrDateUnique', value: '2021-11-29' }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('2021-11-29'); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .date( + { + attr: { type: 'date', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateUnique', value: null }, + entity: null, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.date( + { + attr: { type: 'date', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateUnique', value: '2021-11-29' }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator('2021-11-29')).toBe('2021-11-29'); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrDateUnique: '2021-11-29' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.date( + { + attr: { type: 'date', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateUnique', value: '2021-11-29' }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator('2021-11-29'); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrDateUnique: '2021-11-29' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.date( + { + attr: { type: 'date', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateUnique', value: '2021-11-29' }, + entity: { id: 1, attrDateUnique: '2021-11-29' }, + }, + { isDraft: false } + ) + ); + + expect(await validator('2021-11-29')).toBe('2021-11-29'); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.date( + { + attr: { type: 'date', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateUnique', value: '2021-11-29' }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('2021-11-29'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrDateUnique: '2021-11-29' }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.date( + { + attr: { type: 'date', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateUnique', value: '2021-11-29' }, + entity: { id: 1, attrDateUnique: '2021-12-15' }, + }, + { isDraft: false } + ) + ); + + await validator('2021-11-29'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrDateUnique: '2021-11-29' }, { $not: { id: 1 } }] }, + }); + }); + }); + + describe('DateTime unique validator', () => { + const fakeModel = { + kind: 'contentType', + modelName: 'test-model', + uid: 'test-uid', + privateAttributes: [], + options: {}, + attributes: { + attrDateTimeUnique: { type: 'datetime', unique: true }, + }, + }; + + test('it does not validates the unique constraint if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.datetime( + { + attr: { type: 'datetime' }, + model: fakeModel, + updatedAttribute: { name: 'attrDateTimeUnique', value: '2021-11-29T00:00:00.000Z' }, + entity: { id: 1, attrDateTimeUnique: '2021-11-29T00:00:00.000Z' }, + }, + { isDraft: false } + ) + ); + + await validator('2021-11-29T00:00:00.000Z'); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .datetime( + { + attr: { type: 'datetime', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateTimeUnique', value: null }, + entity: null, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.datetime( + { + attr: { type: 'datetime', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateTimeUnique', value: '2021-11-29T00:00:00.000Z' }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator('2021-11-29T00:00:00.000Z')).toBe('2021-11-29T00:00:00.000Z'); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrDateTimeUnique: '2021-11-29T00:00:00.000Z' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.datetime( + { + attr: { type: 'datetime', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateTimeUnique', value: '2021-11-29T00:00:00.000Z' }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator('2021-11-29T00:00:00.000Z'); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrDateTimeUnique: '2021-11-29T00:00:00.000Z' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.datetime( + { + attr: { type: 'datetime', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateTimeUnique', value: '2021-11-29T00:00:00.000Z' }, + entity: { id: 1, attrDateTimeUnique: '2021-11-29T00:00:00.000Z' }, + }, + { isDraft: false } + ) + ); + + expect(await validator('2021-11-29T00:00:00.000Z')).toBe('2021-11-29T00:00:00.000Z'); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.datetime( + { + attr: { type: 'datetime', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateTimeUnique', value: '2021-11-29T00:00:00.000Z' }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('2021-11-29T00:00:00.000Z'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrDateTimeUnique: '2021-11-29T00:00:00.000Z' }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.datetime( + { + attr: { type: 'datetime', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrDateTimeUnique', value: '2021-11-29T00:00:00.000Z' }, + entity: { id: 1, attrDateTimeUnique: '2021-12-25T00:00:00.000Z' }, + }, + { isDraft: false } + ) + ); + + await validator('2021-11-29T00:00:00.000Z'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrDateTimeUnique: '2021-11-29T00:00:00.000Z' }, { $not: { id: 1 } }] }, + }); + }); + }); + + describe('Time unique validator', () => { + const fakeModel = { + kind: 'contentType', + modelName: 'test-model', + uid: 'test-uid', + privateAttributes: [], + options: {}, + attributes: { + attrTimeUnique: { type: 'time', unique: true }, + }, + }; + + test('it does not validates the unique constraint if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.time( + { + attr: { type: 'time' }, + model: fakeModel, + updatedAttribute: { name: 'attrTimeUnique', value: '00:00:00.000Z' }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('00:00:00.000Z'); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .time( + { + attr: { type: 'time', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrTimeUnique', value: null }, + entity: { id: 1, attrTimeUnique: '00:00:00.000Z' }, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.time( + { + attr: { type: 'time', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrTimeUnique', value: '00:00:00.000Z' }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator('00:00:00.000Z')).toBe('00:00:00.000Z'); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrTimeUnique: '00:00:00.000Z' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.time( + { + attr: { type: 'time', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrTimeUnique', value: '00:00:00.000Z' }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator('00:00:00.000Z'); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrTimeUnique: '00:00:00.000Z' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.time( + { + attr: { type: 'time', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrTimeUnique', value: '00:00:00.000Z' }, + entity: { id: 1, attrTimeUnique: '00:00:00.000Z' }, + }, + { isDraft: false } + ) + ); + + expect(await validator('00:00:00.000Z')).toBe('00:00:00.000Z'); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.time( + { + attr: { type: 'time', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrTimeUnique', value: '00:00:00.000Z' }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('00:00:00.000Z'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrTimeUnique: '00:00:00.000Z' }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.time( + { + attr: { type: 'time', unique: true }, + model: fakeModel, + updatedAttribute: { name: 'attrTimeUnique', value: '00:00:00.000Z' }, + entity: { id: 1, attrTimeUnique: '01:00:00.000Z' }, + }, + { isDraft: false } + ) + ); + + await validator('00:00:00.000Z'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrTimeUnique: '00:00:00.000Z' }, { $not: { id: 1 } }] }, + }); + }); + }); + + describe('Timestamp unique validator', () => { + const fakeModel = { + kind: 'contentType', + modelName: 'test-model', + uid: 'test-uid', + privateAttributes: [], + options: {}, + attributes: { + attrTimestampUnique: { type: 'timestamp', unique: true }, + }, + }; + + test('it does not validates the unique constraint if the attribute is not set as unique', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.timestamp( + { + attr: { type: 'timestamp' }, + model: fakeModel, + updatedAttribute: { + name: 'attrTimestampUnique', + value: '1638140400', + }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('1638140400'); + + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it does not validates the unique constraint if the attribute value is `null`', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator + .timestamp( + { + attr: { type: 'timestamp', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrTimestampUnique', + value: null, + }, + entity: null, + }, + { isDraft: false } + ) + .nullable() + ); + + await validator(null); + expect(fakeFindOne).not.toHaveBeenCalled(); + }); + + test('it validates the unique constraint if there is no other record in the database', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.timestamp( + { + attr: { type: 'timestamp', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrTimestampUnique', + value: '1638140400', + }, + entity: null, + }, + { isDraft: false } + ) + ); + + expect(await validator('1638140400')).toBe('1638140400'); + }); + + test('it fails the validation of the unique constraint if the database contains a record with the same attribute value', async () => { + expect.assertions(1); + fakeFindOne.mockResolvedValueOnce({ attrTimestampUnique: '1638140400' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.timestamp( + { + attr: { type: 'timestamp', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrTimestampUnique', + value: '1638140400', + }, + entity: null, + }, + { isDraft: false } + ) + ); + + try { + await validator('1638140400'); + } catch (err) { + expect(err).toBeInstanceOf(YupValidationError); + } + }); + + test('it validates the unique constraint if the attribute data has not changed even if there is a record in the database with the same attribute value', async () => { + fakeFindOne.mockResolvedValueOnce({ attrTimestampUnique: '1638140400' }); + + const validator = strapiUtils.validateYupSchema( + entityValidator.timestamp( + { + attr: { type: 'timestamp', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrTimestampUnique', + value: '1638140400', + }, + entity: { id: 1, attrTimestampUnique: '1638140400' }, + }, + { isDraft: false } + ) + ); + + expect(await validator('1638140400')).toBe('1638140400'); + }); + + test('it checks the database for records with the same value for the checked attribute', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.timestamp( + { + attr: { type: 'timestamp', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrTimestampUnique', + value: '1638140400', + }, + entity: null, + }, + { isDraft: false } + ) + ); + + await validator('1638140400'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { attrTimestampUnique: '1638140400' }, + }); + }); + + test('it checks the database for records with the same value but not the same id for the checked attribute if an entity is passed', async () => { + fakeFindOne.mockResolvedValueOnce(null); + + const validator = strapiUtils.validateYupSchema( + entityValidator.timestamp( + { + attr: { type: 'timestamp', unique: true }, + model: fakeModel, + updatedAttribute: { + name: 'attrTimestampUnique', + value: '1638140400', + }, + entity: { id: 1, attrTimestampUnique: '1000000000' }, + }, + { isDraft: false } + ) + ); + + await validator('1638140400'); + + expect(fakeFindOne).toHaveBeenCalledWith({ + select: ['id'], + where: { $and: [{ attrTimestampUnique: '1638140400' }, { $not: { id: 1 } }] }, + }); + }); + }); +}); diff --git a/packages/core/strapi/lib/services/entity-validator/index.js b/packages/core/strapi/lib/services/entity-validator/index.js index 022efce405..448d19da03 100644 --- a/packages/core/strapi/lib/services/entity-validator/index.js +++ b/packages/core/strapi/lib/services/entity-validator/index.js @@ -12,8 +12,11 @@ const { yup, validateYupSchema } = strapiUtils; const { isMediaAttribute, isScalarAttribute, getWritableAttributes } = strapiUtils.contentTypes; const { ValidationError } = strapiUtils.errors; -const addMinMax = (attr, validator, data) => { - if (Number.isInteger(attr.min) && (attr.required || (Array.isArray(data) && data.length > 0))) { +const addMinMax = (validator, { attr, updatedAttribute }) => { + if ( + Number.isInteger(attr.min) && + (attr.required || (Array.isArray(updatedAttribute.value) && updatedAttribute.value.length > 0)) + ) { validator = validator.min(attr.min); } if (Number.isInteger(attr.max)) { @@ -22,7 +25,7 @@ const addMinMax = (attr, validator, data) => { return validator; }; -const addRequiredValidation = createOrUpdate => (required, validator) => { +const addRequiredValidation = createOrUpdate => (validator, { attr: { required } }) => { if (required) { if (createOrUpdate === 'creation') { validator = validator.notNil(); @@ -35,7 +38,7 @@ const addRequiredValidation = createOrUpdate => (required, validator) => { return validator; }; -const addDefault = createOrUpdate => (attr, validator) => { +const addDefault = createOrUpdate => (validator, { attr }) => { if (createOrUpdate === 'creation') { if ( ((attr.type === 'component' && attr.repeatable) || attr.type === 'dynamiczone') && @@ -54,7 +57,7 @@ const addDefault = createOrUpdate => (attr, validator) => { const preventCast = validator => validator.transform((val, originalVal) => originalVal); -const createComponentValidator = createOrUpdate => (attr, data, { isDraft }) => { +const createComponentValidator = createOrUpdate => ({ attr, updatedAttribute }, { isDraft }) => { let validator; const model = strapi.getModel(attr.component); @@ -66,19 +69,23 @@ const createComponentValidator = createOrUpdate => (attr, data, { isDraft }) => validator = yup .array() .of( - yup.lazy(item => createModelValidator(createOrUpdate)(model, item, { isDraft }).notNull()) + yup.lazy(item => + createModelValidator(createOrUpdate)({ model, data: item }, { isDraft }).notNull() + ) ); - validator = addRequiredValidation(createOrUpdate)(true, validator); - validator = addMinMax(attr, validator, data); + validator = addRequiredValidation(createOrUpdate)(validator, { attr: { required: true } }); + validator = addMinMax(validator, { attr, updatedAttribute }); } else { - validator = createModelValidator(createOrUpdate)(model, data, { isDraft }); - validator = addRequiredValidation(createOrUpdate)(!isDraft && attr.required, validator); + validator = createModelValidator(createOrUpdate)({ model, updatedAttribute }, { isDraft }); + validator = addRequiredValidation(createOrUpdate)(validator, { + attr: { required: !isDraft && attr.required }, + }); } return validator; }; -const createDzValidator = createOrUpdate => (attr, data, { isDraft }) => { +const createDzValidator = createOrUpdate => ({ attr, updatedAttribute }, { isDraft }) => { let validator; validator = yup.array().of( @@ -95,76 +102,85 @@ const createDzValidator = createOrUpdate => (attr, data, { isDraft }) => { .notNull(); return model - ? schema.concat(createModelValidator(createOrUpdate)(model, item, { isDraft })) + ? schema.concat(createModelValidator(createOrUpdate)({ model, data: item }, { isDraft })) : schema; }) ); - validator = addRequiredValidation(createOrUpdate)(true, validator); - validator = addMinMax(attr, validator, data); + validator = addRequiredValidation(createOrUpdate)(validator, { attr: { required: true } }); + validator = addMinMax(validator, { attr, updatedAttribute }); return validator; }; -const createRelationValidator = createOrUpdate => (attr, data, { isDraft }) => { +const createRelationValidator = createOrUpdate => ({ attr, updatedAttribute }, { isDraft }) => { let validator; - if (Array.isArray(data)) { + if (Array.isArray(updatedAttribute.value)) { validator = yup.array().of(yup.mixed()); } else { validator = yup.mixed(); } - validator = addRequiredValidation(createOrUpdate)(!isDraft && attr.required, validator); + + validator = addRequiredValidation(createOrUpdate)(validator, { + attr: { required: !isDraft && attr.required }, + }); return validator; }; -const createScalarAttributeValidator = createOrUpdate => (attr, { isDraft }) => { +const createScalarAttributeValidator = createOrUpdate => (metas, options) => { let validator; - if (has(attr.type, validators)) { - validator = validators[attr.type](attr, { isDraft }); + if (has(metas.attr.type, validators)) { + validator = validators[metas.attr.type](metas, options); } else { // No validators specified - fall back to mixed validator = yup.mixed(); } - validator = addRequiredValidation(createOrUpdate)(!isDraft && attr.required, validator); + validator = addRequiredValidation(createOrUpdate)(validator, { + attr: { required: !options.isDraft && metas.attr.required }, + }); return validator; }; -const createAttributeValidator = createOrUpdate => (attr, data, { isDraft }) => { +const createAttributeValidator = createOrUpdate => (metas, options) => { let validator; - if (isMediaAttribute(attr)) { + if (isMediaAttribute(metas.attr)) { validator = yup.mixed(); - } else if (isScalarAttribute(attr)) { - validator = createScalarAttributeValidator(createOrUpdate)(attr, { isDraft }); + } else if (isScalarAttribute(metas.attr)) { + validator = createScalarAttributeValidator(createOrUpdate)(metas, options); } else { - if (attr.type === 'component') { - validator = createComponentValidator(createOrUpdate)(attr, data, { isDraft }); - } else if (attr.type === 'dynamiczone') { - validator = createDzValidator(createOrUpdate)(attr, data, { isDraft }); + if (metas.attr.type === 'component') { + validator = createComponentValidator(createOrUpdate)(metas, options); + } else if (metas.attr.type === 'dynamiczone') { + validator = createDzValidator(createOrUpdate)(metas, options); } else { - validator = createRelationValidator(createOrUpdate)(attr, data, { isDraft }); + validator = createRelationValidator(createOrUpdate)(metas, options); } validator = preventCast(validator); } - validator = addDefault(createOrUpdate)(attr, validator); + validator = addDefault(createOrUpdate)(validator, metas); return validator; }; -const createModelValidator = createOrUpdate => (model, data, { isDraft }) => { +const createModelValidator = createOrUpdate => ({ model, data, entity }, options) => { const writableAttributes = model ? getWritableAttributes(model) : []; const schema = writableAttributes.reduce((validators, attributeName) => { const validator = createAttributeValidator(createOrUpdate)( - model.attributes[attributeName], - prop(attributeName, data), - { isDraft } + { + attr: model.attributes[attributeName], + updatedAttribute: { name: attributeName, value: prop(attributeName, data) }, + model, + entity, + }, + options ); return assoc(attributeName, validator)(validators); @@ -173,7 +189,12 @@ const createModelValidator = createOrUpdate => (model, data, { isDraft }) => { return yup.object().shape(schema); }; -const createValidateEntity = createOrUpdate => async (model, data, { isDraft = false } = {}) => { +const createValidateEntity = createOrUpdate => async ( + model, + data, + { isDraft = false } = {}, + entity = null +) => { if (!isObject(data)) { const { displayName } = model.info; @@ -182,7 +203,14 @@ const createValidateEntity = createOrUpdate => async (model, data, { isDraft = f ); } - const validator = createModelValidator(createOrUpdate)(model, data, { isDraft }).required(); + const validator = createModelValidator(createOrUpdate)( + { + model, + data, + entity, + }, + { isDraft } + ).required(); return validateYupSchema(validator, { strict: false, abortEarly: false })(data); }; diff --git a/packages/core/strapi/lib/services/entity-validator/validators.js b/packages/core/strapi/lib/services/entity-validator/validators.js index b6503a9af0..ef039eb676 100644 --- a/packages/core/strapi/lib/services/entity-validator/validators.js +++ b/packages/core/strapi/lib/services/entity-validator/validators.js @@ -4,72 +4,155 @@ const _ = require('lodash'); const { yup } = require('@strapi/utils'); +/** + * @type {import('yup').StringSchema} StringSchema + * @type {import('yup').NumberSchema} NumberSchema + * @type {import('yup').AnySchema} AnySchema + */ + /** * Utility function to compose validators */ -const composeValidators = (...fns) => (attr, { isDraft }) => { - return fns.reduce((validator, fn) => { - return fn(attr, validator, { isDraft }); - }, yup.mixed()); +const composeValidators = (...fns) => (...args) => { + let validator = yup.mixed(); + + // if we receive a schema then use it as base schema for nested composition + if (yup.isSchema(args[0])) { + validator = args[0]; + args = args.slice(1); + } + + return fns.reduce((validator, fn) => fn(validator, ...args), validator); }; /* Validator utils */ /** * Adds minLength validator - * @param {Object} attribute model attribute - * @param {Object} validator yup validator + * @param {StringSchema} validator yup validator + * @param {Object} metas + * @param {{ minLength: Number }} metas.attr model attribute + * @param {Object} options + * @param {boolean} options.isDraft + * + * @returns {StringSchema} */ -const addMinLengthValidator = ({ minLength }, validator, { isDraft }) => - _.isInteger(minLength) && !isDraft ? validator.min(minLength) : validator; +const addMinLengthValidator = (validator, { attr }, { isDraft }) => + _.isInteger(attr.minLength) && !isDraft ? validator.min(attr.minLength) : validator; /** * Adds maxLength validator - * @param {Object} attribute model attribute - * @param {Object} validator yup validator + * @param {StringSchema} validator yup validator + * @param {Object} metas + * @param {{ maxLength: Number }} metas.attr model attribute + * + * @returns {StringSchema} */ -const addMaxLengthValidator = ({ maxLength }, validator) => - _.isInteger(maxLength) ? validator.max(maxLength) : validator; +const addMaxLengthValidator = (validator, { attr }) => + _.isInteger(attr.maxLength) ? validator.max(attr.maxLength) : validator; /** * Adds min integer validator - * @param {Object} attribute model attribute - * @param {Object} validator yup validator + * @param {NumberSchema} validator yup validator + * @param {Object} metas + * @param {{ min: Number }} metas.attr model attribute + * + * @returns {NumberSchema} */ -const addMinIntegerValidator = ({ min }, validator) => - _.isNumber(min) ? validator.min(_.toInteger(min)) : validator; +const addMinIntegerValidator = (validator, { attr }) => + _.isNumber(attr.min) ? validator.min(_.toInteger(attr.min)) : validator; /** * Adds max integer validator - * @param {Object} attribute model attribute - * @param {Object} validator yup validator + * @param {NumberSchema} validator yup validator + * @param {Object} metas + * @param {{ max: Number }} metas.attr model attribute + * + * @returns {NumberSchema} */ -const addMaxIntegerValidator = ({ max }, validator) => - _.isNumber(max) ? validator.max(_.toInteger(max)) : validator; +const addMaxIntegerValidator = (validator, { attr }) => + _.isNumber(attr.max) ? validator.max(_.toInteger(attr.max)) : validator; /** * Adds min float/decimal validator - * @param {Object} attribute model attribute - * @param {Object} validator yup validator + * @param {NumberSchema} validator yup validator + * @param {Object} metas + * @param {{ min: Number }} metas.attr model attribute + * + * @returns {NumberSchema} */ -const addMinFloatValidator = ({ min }, validator) => - _.isNumber(min) ? validator.min(min) : validator; +const addMinFloatValidator = (validator, { attr }) => + _.isNumber(attr.min) ? validator.min(attr.min) : validator; /** * Adds max float/decimal validator - * @param {Object} attribute model attribute - * @param {Object} validator yup validator + * @param {NumberSchema} validator yup validator + * @param {Object} metas model attribute + * @param {{ max: Number }} metas.attr + * + * @returns {NumberSchema} */ -const addMaxFloatValidator = ({ max }, validator) => - _.isNumber(max) ? validator.max(max) : validator; +const addMaxFloatValidator = (validator, { attr }) => + _.isNumber(attr.max) ? validator.max(attr.max) : validator; /** * Adds regex validator - * @param {Object} attribute model attribute - * @param {Object} validator yup validator + * @param {StringSchema} validator yup validator + * @param {Object} metas model attribute + * @param {{ regex: RegExp }} metas.attr + * + * @returns {StringSchema} */ -const addStringRegexValidator = ({ regex }, validator) => - _.isUndefined(regex) ? validator : validator.matches(new RegExp(regex)); +const addStringRegexValidator = (validator, { attr }) => + _.isUndefined(attr.regex) ? validator : validator.matches(new RegExp(attr.regex)); + +/** + * + * @param {AnySchema} validator + * @param {Object} metas + * @param {{ unique: Boolean, type: String }} metas.attr + * @param {{ uid: String }} metas.model + * @param {{ name: String, value: any }} metas.updatedAttribute + * @param {Object} metas.entity + * + * @returns {AnySchema} + */ +const addUniqueValidator = (validator, { attr, model, updatedAttribute, entity }) => { + if (!attr.unique && attr.type !== 'uid') { + return validator; + } + + return validator.test('unique', 'This attribute must be unique', async value => { + /** + * If the attribute value is `null` we want to skip the unique validation. + * Otherwise it'll only accept a single `null` entry in the database. + */ + if (updatedAttribute.value === null) { + return true; + } + + /** + * If the attribute is unchanged we skip the unique verification. This will + * prevent the validator to be triggered in case the user activated the + * unique constraint after already creating multiple entries with + * the same attribute value for that field. + */ + if (entity && updatedAttribute.value === entity[updatedAttribute.name]) { + return true; + } + + let whereParams = entity + ? { $and: [{ [updatedAttribute.name]: value }, { $not: { id: entity.id } }] } + : { [updatedAttribute.name]: value }; + + const record = await strapi.db.query(model.uid).findOne({ + select: ['id'], + where: whereParams, + }); + + return !record; + }); +}; /* Type validators */ @@ -77,29 +160,32 @@ const stringValidator = composeValidators( () => yup.string().transform((val, originalVal) => originalVal), addMinLengthValidator, addMaxLengthValidator, - addStringRegexValidator + addStringRegexValidator, + addUniqueValidator ); -const emailValidator = composeValidators(stringValidator, (attr, validator) => validator.email()); +const emailValidator = composeValidators(stringValidator, validator => validator.email()); -const uidValidator = composeValidators(stringValidator, (attr, validator) => +const uidValidator = composeValidators(stringValidator, validator => validator.matches(new RegExp('^[A-Za-z0-9-_.~]*$')) ); -const enumerationValidator = attr => { +const enumerationValidator = ({ attr }) => { return yup.string().oneOf((Array.isArray(attr.enum) ? attr.enum : [attr.enum]).concat(null)); }; const integerValidator = composeValidators( () => yup.number().integer(), addMinIntegerValidator, - addMaxIntegerValidator + addMaxIntegerValidator, + addUniqueValidator ); const floatValidator = composeValidators( () => yup.number(), addMinFloatValidator, - addMaxFloatValidator + addMaxFloatValidator, + addUniqueValidator ); module.exports = { @@ -113,11 +199,11 @@ module.exports = { uid: uidValidator, json: () => yup.mixed(), integer: integerValidator, - biginteger: () => yup.mixed(), + biginteger: composeValidators(addUniqueValidator), float: floatValidator, decimal: floatValidator, - date: () => yup.mixed(), - time: () => yup.mixed(), - datetime: () => yup.mixed(), - timestamp: () => yup.mixed(), + date: composeValidators(addUniqueValidator), + time: composeValidators(addUniqueValidator), + datetime: composeValidators(addUniqueValidator), + timestamp: composeValidators(addUniqueValidator), }; diff --git a/packages/core/strapi/tests/migrations/migration-draft-publish.test.e2e.js b/packages/core/strapi/tests/migrations/migration-draft-publish.test.e2e.js index 45581d017d..224fe86a7e 100644 --- a/packages/core/strapi/tests/migrations/migration-draft-publish.test.e2e.js +++ b/packages/core/strapi/tests/migrations/migration-draft-publish.test.e2e.js @@ -159,7 +159,7 @@ describe('Migration - draft and publish', () => { expect(body.results[0].publishedAt).toBeUndefined(); }); - test.skip('Unique constraint is kept after disabling the feature', async () => { + test('Unique constraint is kept after disabling the feature', async () => { const dogToCreate = { code: 'sameCode' }; let res = await rq({ diff --git a/packages/core/strapi/tests/migrations/migration-unique-attribute.test.e2e.js b/packages/core/strapi/tests/migrations/migration-unique-attribute.test.e2e.js index cfdaf0174b..a9fd43c429 100644 --- a/packages/core/strapi/tests/migrations/migration-unique-attribute.test.e2e.js +++ b/packages/core/strapi/tests/migrations/migration-unique-attribute.test.e2e.js @@ -43,7 +43,7 @@ const restart = async () => { rq = await createAuthRequest({ strapi }); }; -describe.skip('Migration - unique attribute', () => { +describe('Migration - unique attribute', () => { beforeAll(async () => { await builder .addContentType(dogModel)