Merge pull request #11683 from strapi/v4/unique-validator

[v4] add `unique` validator
This commit is contained in:
Alexandre BODIN 2021-11-29 13:47:56 +01:00 committed by GitHub
commit 3e9e3f13cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1752 additions and 106 deletions

View File

@ -46,7 +46,7 @@ const advancedForm = {
id: getTrad('form.attribute.item.settings.name'), id: getTrad('form.attribute.item.settings.name'),
defaultMessage: 'Settings', 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'), id: getTrad('form.attribute.item.settings.name'),
defaultMessage: 'Settings', 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'), id: getTrad('form.attribute.item.settings.name'),
defaultMessage: 'Settings', 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'), id: getTrad('form.attribute.item.settings.name'),
defaultMessage: 'Settings', defaultMessage: 'Settings',
}, },
items: [ items: [options.required, options.maxLength, options.minLength, options.private],
options.required,
options.unique,
options.maxLength,
options.minLength,
options.private,
],
}, },
], ],
}; };
@ -330,13 +324,7 @@ const advancedForm = {
id: getTrad('form.attribute.item.settings.name'), id: getTrad('form.attribute.item.settings.name'),
defaultMessage: 'Settings', defaultMessage: 'Settings',
}, },
items: [ items: [options.required, options.maxLength, options.minLength, options.private],
options.required,
options.unique,
options.maxLength,
options.minLength,
options.private,
],
}, },
], ],
}; };

View File

@ -51,7 +51,6 @@ const getTypeShape = (attribute, { modelType, attributes } = {}) => {
return { return {
multiple: yup.boolean(), multiple: yup.boolean(),
required: validators.required, required: validators.required,
unique: validators.unique,
allowedTypes: yup allowedTypes: yup
.array() .array()
.of(yup.string().oneOf(['images', 'videos', 'files'])) .of(yup.string().oneOf(['images', 'videos', 'files']))
@ -129,7 +128,6 @@ const getTypeShape = (attribute, { modelType, attributes } = {}) => {
return { return {
default: yup.mixed().test(isValidDefaultJSON), default: yup.mixed().test(isValidDefaultJSON),
required: validators.required, required: validators.required,
unique: validators.unique,
}; };
} }
case 'enumeration': { case 'enumeration': {
@ -148,7 +146,6 @@ const getTypeShape = (attribute, { modelType, attributes } = {}) => {
default: yup.string().when('enum', enumVal => yup.string().oneOf(enumVal)), default: yup.string().when('enum', enumVal => yup.string().oneOf(enumVal)),
enumName: yup.string().test(isValidName), enumName: yup.string().test(isValidName),
required: validators.required, required: validators.required,
unique: validators.unique,
}; };
} }
case 'password': { case 'password': {
@ -225,7 +222,6 @@ const getTypeShape = (attribute, { modelType, attributes } = {}) => {
return { return {
default: yup.boolean(), default: yup.boolean(),
required: validators.required, required: validators.required,
unique: validators.unique,
}; };
} }

View File

@ -202,9 +202,14 @@ const createDefaultImplementation = ({ strapi, db, eventHub, entityValidator })
const isDraft = contentTypesUtils.isDraft(entityToUpdate, model); const isDraft = contentTypesUtils.isDraft(entityToUpdate, model);
const validData = await entityValidator.validateEntityUpdate(model, data, { const validData = await entityValidator.validateEntityUpdate(
isDraft, model,
}); data,
{
isDraft,
},
entityToUpdate
);
const query = transformParamsToQuery(uid, pickSelectionParams(wrappedParams)); const query = transformParamsToQuery(uid, pickSelectionParams(wrappedParams));

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,11 @@ const { yup, validateYupSchema } = strapiUtils;
const { isMediaAttribute, isScalarAttribute, getWritableAttributes } = strapiUtils.contentTypes; const { isMediaAttribute, isScalarAttribute, getWritableAttributes } = strapiUtils.contentTypes;
const { ValidationError } = strapiUtils.errors; const { ValidationError } = strapiUtils.errors;
const addMinMax = (attr, validator, data) => { const addMinMax = (validator, { attr, updatedAttribute }) => {
if (Number.isInteger(attr.min) && (attr.required || (Array.isArray(data) && data.length > 0))) { if (
Number.isInteger(attr.min) &&
(attr.required || (Array.isArray(updatedAttribute.value) && updatedAttribute.value.length > 0))
) {
validator = validator.min(attr.min); validator = validator.min(attr.min);
} }
if (Number.isInteger(attr.max)) { if (Number.isInteger(attr.max)) {
@ -22,7 +25,7 @@ const addMinMax = (attr, validator, data) => {
return validator; return validator;
}; };
const addRequiredValidation = createOrUpdate => (required, validator) => { const addRequiredValidation = createOrUpdate => (validator, { attr: { required } }) => {
if (required) { if (required) {
if (createOrUpdate === 'creation') { if (createOrUpdate === 'creation') {
validator = validator.notNil(); validator = validator.notNil();
@ -35,7 +38,7 @@ const addRequiredValidation = createOrUpdate => (required, validator) => {
return validator; return validator;
}; };
const addDefault = createOrUpdate => (attr, validator) => { const addDefault = createOrUpdate => (validator, { attr }) => {
if (createOrUpdate === 'creation') { if (createOrUpdate === 'creation') {
if ( if (
((attr.type === 'component' && attr.repeatable) || attr.type === 'dynamiczone') && ((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 preventCast = validator => validator.transform((val, originalVal) => originalVal);
const createComponentValidator = createOrUpdate => (attr, data, { isDraft }) => { const createComponentValidator = createOrUpdate => ({ attr, updatedAttribute }, { isDraft }) => {
let validator; let validator;
const model = strapi.getModel(attr.component); const model = strapi.getModel(attr.component);
@ -66,19 +69,23 @@ const createComponentValidator = createOrUpdate => (attr, data, { isDraft }) =>
validator = yup validator = yup
.array() .array()
.of( .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 = addRequiredValidation(createOrUpdate)(validator, { attr: { required: true } });
validator = addMinMax(attr, validator, data); validator = addMinMax(validator, { attr, updatedAttribute });
} else { } else {
validator = createModelValidator(createOrUpdate)(model, data, { isDraft }); validator = createModelValidator(createOrUpdate)({ model, updatedAttribute }, { isDraft });
validator = addRequiredValidation(createOrUpdate)(!isDraft && attr.required, validator); validator = addRequiredValidation(createOrUpdate)(validator, {
attr: { required: !isDraft && attr.required },
});
} }
return validator; return validator;
}; };
const createDzValidator = createOrUpdate => (attr, data, { isDraft }) => { const createDzValidator = createOrUpdate => ({ attr, updatedAttribute }, { isDraft }) => {
let validator; let validator;
validator = yup.array().of( validator = yup.array().of(
@ -95,76 +102,85 @@ const createDzValidator = createOrUpdate => (attr, data, { isDraft }) => {
.notNull(); .notNull();
return model return model
? schema.concat(createModelValidator(createOrUpdate)(model, item, { isDraft })) ? schema.concat(createModelValidator(createOrUpdate)({ model, data: item }, { isDraft }))
: schema; : schema;
}) })
); );
validator = addRequiredValidation(createOrUpdate)(true, validator); validator = addRequiredValidation(createOrUpdate)(validator, { attr: { required: true } });
validator = addMinMax(attr, validator, data); validator = addMinMax(validator, { attr, updatedAttribute });
return validator; return validator;
}; };
const createRelationValidator = createOrUpdate => (attr, data, { isDraft }) => { const createRelationValidator = createOrUpdate => ({ attr, updatedAttribute }, { isDraft }) => {
let validator; let validator;
if (Array.isArray(data)) { if (Array.isArray(updatedAttribute.value)) {
validator = yup.array().of(yup.mixed()); validator = yup.array().of(yup.mixed());
} else { } else {
validator = yup.mixed(); validator = yup.mixed();
} }
validator = addRequiredValidation(createOrUpdate)(!isDraft && attr.required, validator);
validator = addRequiredValidation(createOrUpdate)(validator, {
attr: { required: !isDraft && attr.required },
});
return validator; return validator;
}; };
const createScalarAttributeValidator = createOrUpdate => (attr, { isDraft }) => { const createScalarAttributeValidator = createOrUpdate => (metas, options) => {
let validator; let validator;
if (has(attr.type, validators)) { if (has(metas.attr.type, validators)) {
validator = validators[attr.type](attr, { isDraft }); validator = validators[metas.attr.type](metas, options);
} else { } else {
// No validators specified - fall back to mixed // No validators specified - fall back to mixed
validator = yup.mixed(); validator = yup.mixed();
} }
validator = addRequiredValidation(createOrUpdate)(!isDraft && attr.required, validator); validator = addRequiredValidation(createOrUpdate)(validator, {
attr: { required: !options.isDraft && metas.attr.required },
});
return validator; return validator;
}; };
const createAttributeValidator = createOrUpdate => (attr, data, { isDraft }) => { const createAttributeValidator = createOrUpdate => (metas, options) => {
let validator; let validator;
if (isMediaAttribute(attr)) { if (isMediaAttribute(metas.attr)) {
validator = yup.mixed(); validator = yup.mixed();
} else if (isScalarAttribute(attr)) { } else if (isScalarAttribute(metas.attr)) {
validator = createScalarAttributeValidator(createOrUpdate)(attr, { isDraft }); validator = createScalarAttributeValidator(createOrUpdate)(metas, options);
} else { } else {
if (attr.type === 'component') { if (metas.attr.type === 'component') {
validator = createComponentValidator(createOrUpdate)(attr, data, { isDraft }); validator = createComponentValidator(createOrUpdate)(metas, options);
} else if (attr.type === 'dynamiczone') { } else if (metas.attr.type === 'dynamiczone') {
validator = createDzValidator(createOrUpdate)(attr, data, { isDraft }); validator = createDzValidator(createOrUpdate)(metas, options);
} else { } else {
validator = createRelationValidator(createOrUpdate)(attr, data, { isDraft }); validator = createRelationValidator(createOrUpdate)(metas, options);
} }
validator = preventCast(validator); validator = preventCast(validator);
} }
validator = addDefault(createOrUpdate)(attr, validator); validator = addDefault(createOrUpdate)(validator, metas);
return validator; return validator;
}; };
const createModelValidator = createOrUpdate => (model, data, { isDraft }) => { const createModelValidator = createOrUpdate => ({ model, data, entity }, options) => {
const writableAttributes = model ? getWritableAttributes(model) : []; const writableAttributes = model ? getWritableAttributes(model) : [];
const schema = writableAttributes.reduce((validators, attributeName) => { const schema = writableAttributes.reduce((validators, attributeName) => {
const validator = createAttributeValidator(createOrUpdate)( const validator = createAttributeValidator(createOrUpdate)(
model.attributes[attributeName], {
prop(attributeName, data), attr: model.attributes[attributeName],
{ isDraft } updatedAttribute: { name: attributeName, value: prop(attributeName, data) },
model,
entity,
},
options
); );
return assoc(attributeName, validator)(validators); return assoc(attributeName, validator)(validators);
@ -173,7 +189,12 @@ const createModelValidator = createOrUpdate => (model, data, { isDraft }) => {
return yup.object().shape(schema); 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)) { if (!isObject(data)) {
const { displayName } = model.info; 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); return validateYupSchema(validator, { strict: false, abortEarly: false })(data);
}; };

View File

@ -4,72 +4,155 @@ const _ = require('lodash');
const { yup } = require('@strapi/utils'); 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 * Utility function to compose validators
*/ */
const composeValidators = (...fns) => (attr, { isDraft }) => { const composeValidators = (...fns) => (...args) => {
return fns.reduce((validator, fn) => { let validator = yup.mixed();
return fn(attr, validator, { isDraft });
}, 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 */ /* Validator utils */
/** /**
* Adds minLength validator * Adds minLength validator
* @param {Object} attribute model attribute * @param {StringSchema} validator yup validator
* @param {Object} 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 }) => const addMinLengthValidator = (validator, { attr }, { isDraft }) =>
_.isInteger(minLength) && !isDraft ? validator.min(minLength) : validator; _.isInteger(attr.minLength) && !isDraft ? validator.min(attr.minLength) : validator;
/** /**
* Adds maxLength validator * Adds maxLength validator
* @param {Object} attribute model attribute * @param {StringSchema} validator yup validator
* @param {Object} validator yup validator * @param {Object} metas
* @param {{ maxLength: Number }} metas.attr model attribute
*
* @returns {StringSchema}
*/ */
const addMaxLengthValidator = ({ maxLength }, validator) => const addMaxLengthValidator = (validator, { attr }) =>
_.isInteger(maxLength) ? validator.max(maxLength) : validator; _.isInteger(attr.maxLength) ? validator.max(attr.maxLength) : validator;
/** /**
* Adds min integer validator * Adds min integer validator
* @param {Object} attribute model attribute * @param {NumberSchema} validator yup validator
* @param {Object} validator yup validator * @param {Object} metas
* @param {{ min: Number }} metas.attr model attribute
*
* @returns {NumberSchema}
*/ */
const addMinIntegerValidator = ({ min }, validator) => const addMinIntegerValidator = (validator, { attr }) =>
_.isNumber(min) ? validator.min(_.toInteger(min)) : validator; _.isNumber(attr.min) ? validator.min(_.toInteger(attr.min)) : validator;
/** /**
* Adds max integer validator * Adds max integer validator
* @param {Object} attribute model attribute * @param {NumberSchema} validator yup validator
* @param {Object} validator yup validator * @param {Object} metas
* @param {{ max: Number }} metas.attr model attribute
*
* @returns {NumberSchema}
*/ */
const addMaxIntegerValidator = ({ max }, validator) => const addMaxIntegerValidator = (validator, { attr }) =>
_.isNumber(max) ? validator.max(_.toInteger(max)) : validator; _.isNumber(attr.max) ? validator.max(_.toInteger(attr.max)) : validator;
/** /**
* Adds min float/decimal validator * Adds min float/decimal validator
* @param {Object} attribute model attribute * @param {NumberSchema} validator yup validator
* @param {Object} validator yup validator * @param {Object} metas
* @param {{ min: Number }} metas.attr model attribute
*
* @returns {NumberSchema}
*/ */
const addMinFloatValidator = ({ min }, validator) => const addMinFloatValidator = (validator, { attr }) =>
_.isNumber(min) ? validator.min(min) : validator; _.isNumber(attr.min) ? validator.min(attr.min) : validator;
/** /**
* Adds max float/decimal validator * Adds max float/decimal validator
* @param {Object} attribute model attribute * @param {NumberSchema} validator yup validator
* @param {Object} validator yup validator * @param {Object} metas model attribute
* @param {{ max: Number }} metas.attr
*
* @returns {NumberSchema}
*/ */
const addMaxFloatValidator = ({ max }, validator) => const addMaxFloatValidator = (validator, { attr }) =>
_.isNumber(max) ? validator.max(max) : validator; _.isNumber(attr.max) ? validator.max(attr.max) : validator;
/** /**
* Adds regex validator * Adds regex validator
* @param {Object} attribute model attribute * @param {StringSchema} validator yup validator
* @param {Object} validator yup validator * @param {Object} metas model attribute
* @param {{ regex: RegExp }} metas.attr
*
* @returns {StringSchema}
*/ */
const addStringRegexValidator = ({ regex }, validator) => const addStringRegexValidator = (validator, { attr }) =>
_.isUndefined(regex) ? validator : validator.matches(new RegExp(regex)); _.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 */ /* Type validators */
@ -77,29 +160,32 @@ const stringValidator = composeValidators(
() => yup.string().transform((val, originalVal) => originalVal), () => yup.string().transform((val, originalVal) => originalVal),
addMinLengthValidator, addMinLengthValidator,
addMaxLengthValidator, 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-_.~]*$')) 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)); return yup.string().oneOf((Array.isArray(attr.enum) ? attr.enum : [attr.enum]).concat(null));
}; };
const integerValidator = composeValidators( const integerValidator = composeValidators(
() => yup.number().integer(), () => yup.number().integer(),
addMinIntegerValidator, addMinIntegerValidator,
addMaxIntegerValidator addMaxIntegerValidator,
addUniqueValidator
); );
const floatValidator = composeValidators( const floatValidator = composeValidators(
() => yup.number(), () => yup.number(),
addMinFloatValidator, addMinFloatValidator,
addMaxFloatValidator addMaxFloatValidator,
addUniqueValidator
); );
module.exports = { module.exports = {
@ -113,11 +199,11 @@ module.exports = {
uid: uidValidator, uid: uidValidator,
json: () => yup.mixed(), json: () => yup.mixed(),
integer: integerValidator, integer: integerValidator,
biginteger: () => yup.mixed(), biginteger: composeValidators(addUniqueValidator),
float: floatValidator, float: floatValidator,
decimal: floatValidator, decimal: floatValidator,
date: () => yup.mixed(), date: composeValidators(addUniqueValidator),
time: () => yup.mixed(), time: composeValidators(addUniqueValidator),
datetime: () => yup.mixed(), datetime: composeValidators(addUniqueValidator),
timestamp: () => yup.mixed(), timestamp: composeValidators(addUniqueValidator),
}; };

View File

@ -159,7 +159,7 @@ describe('Migration - draft and publish', () => {
expect(body.results[0].publishedAt).toBeUndefined(); 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' }; const dogToCreate = { code: 'sameCode' };
let res = await rq({ let res = await rq({

View File

@ -43,7 +43,7 @@ const restart = async () => {
rq = await createAuthRequest({ strapi }); rq = await createAuthRequest({ strapi });
}; };
describe.skip('Migration - unique attribute', () => { describe('Migration - unique attribute', () => {
beforeAll(async () => { beforeAll(async () => {
await builder await builder
.addContentType(dogModel) .addContentType(dogModel)