import _ from 'lodash'; import { yup } from '@strapi/utils'; import { blocksValidator } from './blocks-validator.mjs'; /* Validator utils */ const toNumberSafe = (value)=>{ if (value === undefined || value === null) return undefined; const num = Number(value); return Number.isFinite(num) ? num : undefined; }; const BIG_INTEGER_REGEX = /^[+-]?\d+$/; const toBigIntegerString = (value)=>{ if (value == null) { return undefined; } if (typeof value === 'bigint') { return value.toString(); } if (typeof value === 'number') { if (!Number.isFinite(value) || !Number.isInteger(value)) { return undefined; } return value.toString(); } if (typeof value === 'string') { const trimmedValue = value.trim(); if (!BIG_INTEGER_REGEX.test(trimmedValue)) { return undefined; } return BigInt(trimmedValue).toString(); } return undefined; }; const isValidBigInteger = (value)=>toBigIntegerString(value) != null; const isValidFiniteNumber = (value)=>typeof value === 'number' && Number.isFinite(value); const isValidInteger = (value)=>isValidFiniteNumber(value) && Number.isInteger(value); const shouldSkipUniqueValidation = (attrType, value)=>{ switch(attrType){ case 'integer': return !isValidInteger(value); case 'float': case 'decimal': return !isValidFiniteNumber(value); case 'biginteger': return !isValidBigInteger(value); default: return false; } }; /** * Adds minLength validator */ const addMinLengthValidator = (validator, { attr }, { isDraft })=>{ return attr.minLength && _.isInteger(attr.minLength) && !isDraft ? validator.min(attr.minLength) : validator; }; /** * Adds maxLength validator * @returns {StringSchema} */ const addMaxLengthValidator = (validator, { attr })=>{ return attr.maxLength && _.isInteger(attr.maxLength) ? validator.max(attr.maxLength) : validator; }; /** * Adds min integer validator * @returns {NumberSchema} */ const addMinIntegerValidator = (validator, { attr }, { isDraft })=>{ const min = toNumberSafe(attr.min); return min !== undefined && !isDraft ? validator.min(_.toInteger(min)) : validator; }; /** * Adds max integer validator */ const addMaxIntegerValidator = (validator, { attr })=>{ const max = toNumberSafe(attr.max); return max !== undefined ? validator.max(_.toInteger(max)) : validator; }; /** * Adds min float/decimal validator */ const addMinFloatValidator = (validator, { attr }, { isDraft })=>{ const min = toNumberSafe(attr.min); return min !== undefined && !isDraft ? validator.min(min) : validator; }; /** * Adds max float/decimal validator */ const addMaxFloatValidator = (validator, { attr })=>{ const max = toNumberSafe(attr.max); return max !== undefined ? validator.max(max) : validator; }; /** * Adds regex validator */ const addStringRegexValidator = (validator, { attr }, { isDraft })=>{ return 'regex' in attr && !_.isUndefined(attr.regex) && !isDraft ? validator.matches(new RegExp(attr.regex), { excludeEmptyString: !attr.required }) : validator; }; const addUniqueValidator = (validator, { attr, model, updatedAttribute, entity, componentContext }, options)=>{ if (attr.type !== 'uid' && !attr.unique) { return validator; } const validateUniqueFieldWithinComponent = async (value)=>{ if (!componentContext) { return false; } // If we are validating a unique field within a repeatable component, // we first need to ensure that the repeatable in the current entity is // valid against itself. const hasRepeatableData = componentContext.repeatableData.length > 0; if (hasRepeatableData) { const { name: updatedName, value: updatedValue } = updatedAttribute; // Construct the full path to the unique field within the component. const pathToCheck = [ ...componentContext.pathToComponent.slice(1), updatedName ].join('.'); // Extract the values from the repeatable data using the constructed path const values = componentContext.repeatableData.map((item)=>{ return pathToCheck.split('.').reduce((acc, key)=>acc[key], item); }); // Check if the value is repeated in the current entity const isUpdatedAttributeRepeatedInThisEntity = values.filter((value)=>value === updatedValue).length > 1; if (isUpdatedAttributeRepeatedInThisEntity) { return false; } } /** * When `componentContext` is present it means we are dealing with a unique * field within a component. * * The unique validation must consider the specific context of the * component, which will always be contained within a parent content type * and may also be nested within another component. * * We construct a query that takes into account the parent's model UID, * dimensions (such as draft and publish state/locale) and excludes the current * content type entity by its ID if provided. */ const { model: parentModel, options: parentOptions, id: excludeId } = componentContext.parentContent; const whereConditions = {}; const isParentDraft = parentOptions && parentOptions.isDraft; whereConditions.publishedAt = isParentDraft ? null : { $notNull: true }; if (parentOptions?.locale) { whereConditions.locale = parentOptions.locale; } if (excludeId && !Number.isNaN(excludeId)) { whereConditions.id = { $ne: excludeId }; } const queryUid = parentModel.uid; const queryWhere = { ...componentContext.pathToComponent.reduceRight((acc, key)=>({ [key]: acc }), { [updatedAttribute.name]: value }), ...whereConditions }; // The validation should pass if there is no other record found from the query return !await strapi.db.query(queryUid).findOne({ where: queryWhere }); }; const validateUniqueFieldWithinDynamicZoneComponent = async (startOfPath)=>{ if (!componentContext) { return false; } const targetComponentUID = model.uid; // Ensure that the value is unique within the dynamic zone in this entity. const countOfValueInThisEntity = (componentContext?.fullDynamicZoneContent ?? []).reduce((acc, component)=>{ if (component.__component !== targetComponentUID) { return acc; } const updatedValue = component[updatedAttribute.name]; return updatedValue === updatedAttribute.value ? acc + 1 : acc; }, 0); if (countOfValueInThisEntity > 1) { // If the value is repeated in the current entity, the validation fails. return false; } // Populate the dynamic zone for any components that share the same value // as the updated attribute. const query = { select: [ 'id' ], where: {}, populate: { [startOfPath]: { on: { [targetComponentUID]: { select: [ 'id' ], where: { [updatedAttribute.name]: updatedAttribute.value } } } } } }; const { options, id } = componentContext.parentContent; if (options?.isDraft !== undefined) { query.where.published_at = options.isDraft ? { $eq: null } : { $ne: null }; } if (id) { query.where.id = { $ne: id }; } if (options?.locale) { query.where.locale = options.locale; } const parentModelQueryResult = await strapi.db.query(componentContext.parentContent.model.uid).findMany(query); // Filter the results to only include results that have components in the // dynamic zone that match the target component type. const filteredResults = parentModelQueryResult.filter((result)=>Array.isArray(result[startOfPath]) && result[startOfPath].length).flatMap((result)=>result[startOfPath]).filter((dynamicZoneComponent)=>dynamicZoneComponent.__component === targetComponentUID); if (filteredResults.length >= 1) { return false; } return true; }; return validator.test('unique', 'This attribute must be unique', async (value)=>{ /** * If the attribute value is `null` or an empty string we want to skip the unique validation. * Otherwise it'll only accept a single entry with that value in the database. */ if (_.isNil(value) || value === '') { return true; } // Skip unique checks for invalid scalar numeric values and let the type validator fail. // This prevents malformed values from reaching the DB query layer when abortEarly=false. if (shouldSkipUniqueValidation(attr.type, value)) { return true; } /** * We don't validate any unique constraint for draft entries. */ if (options.isDraft) { return true; } const hasPathToComponent = componentContext && componentContext.pathToComponent.length > 0; if (hasPathToComponent) { // Detect if we are validating within a dynamiczone by checking if the first // path is a dynamiczone attribute in the parent content type. const startOfPath = componentContext.pathToComponent[0]; const testingDZ = componentContext.parentContent.model.attributes[startOfPath].type === 'dynamiczone'; if (testingDZ) { return validateUniqueFieldWithinDynamicZoneComponent(startOfPath); } return validateUniqueFieldWithinComponent(value); } /** * Here we are validating a scalar unique field from the content type's schema. * We construct a query to check if the value is unique * considering dimensions (e.g. locale, publication state) and excluding the current entity by its ID if available. */ const scalarAttributeWhere = { [updatedAttribute.name]: value, publishedAt: { $notNull: true } }; if (options?.locale) { scalarAttributeWhere.locale = options.locale; } if (entity?.id) { scalarAttributeWhere.id = { $ne: entity.id }; } // The validation should pass if there is no other record found from the query return !await strapi.db.query(model.uid).findOne({ where: scalarAttributeWhere, select: [ 'id' ] }); }); }; /* Type validators */ const stringValidator = (metas, options)=>{ let schema = yup.string().transform((val, originalVal)=>originalVal); schema = addMinLengthValidator(schema, metas, options); schema = addMaxLengthValidator(schema, metas); schema = addStringRegexValidator(schema, metas, options); schema = addUniqueValidator(schema, metas, options); return schema; }; const emailValidator = (metas, options)=>{ const schema = stringValidator(metas, options); if (options.isDraft) { return schema; } return schema.email().min(1, // eslint-disable-next-line no-template-curly-in-string '${path} cannot be empty'); }; const uidValidator = (metas, options)=>{ const schema = stringValidator(metas, options); if (options.isDraft) { return schema; } if (metas.attr.regex) { return schema.matches(new RegExp(metas.attr.regex)); } return schema.matches(/^[A-Za-z0-9-_.~]*$/); }; const enumerationValidator = ({ attr })=>{ return yup.string().oneOf((Array.isArray(attr.enum) ? attr.enum : [ attr.enum ]).concat(null)); }; const integerValidator = (metas, options)=>{ let schema = yup.number().integer(); schema = addMinIntegerValidator(schema, metas, options); schema = addMaxIntegerValidator(schema, metas); schema = addUniqueValidator(schema, metas, options); return schema; }; const floatValidator = (metas, options)=>{ let schema = yup.number().test('is-finite-number', // eslint-disable-next-line no-template-curly-in-string -- Yup interpolates validation path placeholders. '${path} must be a finite number', (value)=>value == null || isValidFiniteNumber(value)); schema = addMinFloatValidator(schema, metas, options); schema = addMaxFloatValidator(schema, metas); schema = addUniqueValidator(schema, metas, options); return schema; }; const bigintegerValidator = (metas, options)=>{ const schema = yup.mixed().transform((value, originalValue)=>toBigIntegerString(originalValue) ?? value)// eslint-disable-next-line no-template-curly-in-string -- Yup interpolates validation path placeholders. .test('is-biginteger', '${path} must be a valid integer', (value)=>{ if (value == null) { return true; } return isValidBigInteger(value); }); return addUniqueValidator(schema, metas, options); }; const datesValidator = (metas, options)=>{ const schema = yup.mixed(); return addUniqueValidator(schema, metas, options); }; const Validators = { string: stringValidator, text: stringValidator, richtext: stringValidator, password: stringValidator, email: emailValidator, enumeration: enumerationValidator, boolean: ()=>yup.boolean().nullable(), uid: uidValidator, json: ()=>yup.mixed(), integer: integerValidator, biginteger: bigintegerValidator, float: floatValidator, decimal: floatValidator, date: datesValidator, time: datesValidator, datetime: datesValidator, timestamp: datesValidator, blocks: blocksValidator }; export { Validators, bigintegerValidator, datesValidator, emailValidator, enumerationValidator, floatValidator, integerValidator, uidValidator }; //# sourceMappingURL=validators.mjs.map