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'),
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],
},
],
};

View File

@ -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,
};
}

View File

@ -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));

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 { 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);
};

View File

@ -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),
};

View File

@ -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({

View File

@ -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)