UNPKG

nuvira

Version:

Nuvira Database. New Database format (Readable & Easy to use), (Inbuilt Schema & constraints & rules & relations).

508 lines 20.5 kB
/** * A class that validates a record against a provided schema dictionary. * * Allowed types include: * - Primitives: "Number", "String", "Boolean", "Date", "Binary", "Uint8Array" * - Objects: "Object" * - Arrays: * * Generic arrays: "Any[]", "AnyArray", "Array", "[]" * * Specific arrays: "StringArray", "String[]", "NumberArray", "Number[]", "ObjectArray", "Object[]" * - Special values: "Null", "undefined", "Any" */ export class SchemaValidator { schemaDict; constructor(schemaDict) { this.schemaDict = schemaDict; } /** * Validates a record against the top-level schema dictionary. * * For example, if the schema is: * { * Users: { * type: [ "ObjectArray" ], * items: { ... } * } * } * * then this method checks that: * - The record has a key "Users". * - The value at "Users" matches the "ObjectArray" schema. * * @param record - The record to validate. * @returns A ValidationResult with `valid` and error details. */ validate(record) { const errors = this.validateRecordAgainstSchemaDict(this.schemaDict, record, ''); return { valid: errors.length === 0, errors }; } /** * Validates a record (object) against a top-level schema dictionary. * * Also checks for extra keys not mentioned in the schema. */ validateRecordAgainstSchemaDict(schemaDict, record, path) { const errors = []; for (const key in schemaDict) { const currentPath = path ? `${path}.${key}` : key; if (!Object.prototype.hasOwnProperty.call(record, key)) { errors.push(`Missing required key '${key}' at path '${path || 'root'}'.`); } else { errors.push(...this.validateAgainstSchema(schemaDict[key], record[key], currentPath)); } } for (const key in record) { if (!schemaDict.hasOwnProperty(key)) { const currentPath = path ? `${path}.${key}` : key; errors.push(`Unexpected key '${key}' found at path '${path || 'root'}'.`); } } return errors; } /** * Validates a value against a given schema. * * If multiple allowed types are specified, the value is valid if it matches at least one. * Otherwise, detailed error messages are returned. * * @param schema - The schema definition. * @param record - The value to validate. * @param path - The path of the value (for error messages). * @returns An array of error messages; an empty array means valid. */ validateAgainstSchema(schema, record, path) { const attempts = []; for (const allowedType of schema.type) { const errors = this.checkAgainstAllowedType(allowedType, schema, record, path); if (errors.length === 0) { return []; } attempts.push(errors); } const combinedErrors = attempts .map((errs, index) => `Option ${index + 1}: ${errs.join('; ')}`) .join(' OR '); return [`Value at '${path}' does not match any allowed types. Details: ${combinedErrors}`]; } /** * Normalizes the `items` field if it is provided as a dictionary (i.e., without a "type" field). * * For example, if you receive: * items: { * name: { type: ["String"] }, * age: { type: ["Number"] }, * ... * } * then this function wraps it as: * { * type: ["Object"], * properties: { ... } * } * * @param schema - The schema object which may have an `items` field. * @returns A normalized Schema for the items, or `undefined` if no items are provided. */ normalizeItems(schema) { if (!schema.items) return undefined; if (typeof schema.items === 'object' && !('type' in schema.items)) { return { type: ['Object'], properties: schema.items, }; } return schema.items; } /** * Checks if the record matches a single allowed type within the schema. * * @param allowedType - The type string to validate against. * @param schema - The full schema (which may contain additional info like properties or items). * @param record - The value to validate. * @param path - The path of the value (for error messages). * @returns An array of error messages; an empty array means the value is valid for this allowed type. */ checkAgainstAllowedType(allowedType, schema, record, path) { const describe = (value) => { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (value instanceof Date) return 'Date'; if (value instanceof ArrayBuffer) return 'ArrayBuffer'; if (value instanceof Uint8Array) return 'Uint8Array'; return typeof value; }; if (allowedType === 'Null') { return record === null ? [] : [`Expected Null at '${path}', but got ${describe(record)}.`]; } if (allowedType === 'undefined') { return record === undefined ? [] : [`Expected undefined at '${path}', but got ${describe(record)}.`]; } switch (allowedType) { case 'Any': return []; case 'String': return typeof record === 'string' ? [] : [`Expected String at '${path}', but got ${describe(record)}.`]; case 'Number': return typeof record === 'number' ? [] : [`Expected Number at '${path}', but got ${describe(record)}.`]; case 'Boolean': return typeof record === 'boolean' ? [] : [`Expected Boolean at '${path}', but got ${describe(record)}.`]; case 'Date': return record instanceof Date ? [] : [`Expected Date at '${path}', but got ${describe(record)}.`]; case 'Binary': return record instanceof ArrayBuffer || record instanceof Uint8Array ? [] : [`Expected Binary (ArrayBuffer or Uint8Array) at '${path}', but got ${describe(record)}.`]; case 'Uint8Array': return record instanceof Uint8Array ? [] : [`Expected Uint8Array at '${path}', but got ${describe(record)}.`]; case 'Object': { if (typeof record === 'object' && record !== null && !Array.isArray(record)) { const objErrors = []; if (schema.properties) { for (const key in schema.properties) { const currentPath = `${path}.${key}`; if (!Object.prototype.hasOwnProperty.call(record, key)) { objErrors.push(`Missing required key '${key}' at '${path}'.`); } else { objErrors.push(...this.validateAgainstSchema(schema.properties[key], record[key], currentPath)); } } for (const key in record) { if (!schema.properties.hasOwnProperty(key)) { objErrors.push(`Unexpected key '${key}' found at '${path}'.`); } } } return objErrors; } return [`Expected Object at '${path}', but got ${describe(record)}.`]; } case 'Any[]': case 'AnyArray': case 'Array': case '[]': return Array.isArray(record) ? [] : [`Expected an Array at '${path}', but got ${describe(record)}.`]; case 'StringArray': case 'String[]': { if (!Array.isArray(record)) { return [`Expected an Array at '${path}', but got ${describe(record)}.`]; } const arrErrors = []; record.forEach((item, index) => { if (typeof item !== 'string') { arrErrors.push(`Expected String at '${path}[${index}]', but got ${describe(item)}.`); } }); return arrErrors; } case 'NumberArray': case 'Number[]': { if (!Array.isArray(record)) { return [`Expected an Array at '${path}', but got ${describe(record)}.`]; } const arrErrors = []; record.forEach((item, index) => { if (typeof item !== 'number') { arrErrors.push(`Expected Number at '${path}[${index}]', but got ${describe(item)}.`); } }); return arrErrors; } case 'ObjectArray': case 'Object[]': { if (!Array.isArray(record)) { return [`Expected an Array at '${path}', but got ${describe(record)}.`]; } const itemSchema = this.normalizeItems(schema); if (!itemSchema) { return [`No items schema provided for '${path}'.`]; } const arrErrors = []; record.forEach((item, index) => { const itemErrors = this.validateAgainstSchema(itemSchema, item, `${path}[${index}]`); arrErrors.push(...itemErrors); }); return arrErrors; } default: return [`Unknown allowed type '${allowedType}' at '${path}'.`]; } } } /** * A class that validates record data against a nested validations object. * * The validations object is expected to be structured similarly to: * * { * Users: { * rules: { required: true, maxLength: 50, isUnique: true }, * name: { rules: { required: true, minLength: 3, uppercase: true, isText: true } }, * friends: { rules: { minLength: 3, uppercase: true, isText: true } } * }, * name: { * rules: { required: true, minLength: 3, isText: true } * } * } */ export class ValidationRulesValidator { validationKeywords = { minLength: [ 'String', 'StringArray', 'String[]', 'ObjectArray', 'Object[]', 'Array', 'Any[]', '[]', 'Object', 'NumberArray', 'Number[]', 'Uint8Array' ], maxLength: [ 'String', 'StringArray', 'String[]', 'ObjectArray', 'Object[]', 'Array', 'Any[]', '[]', 'Object', 'NumberArray', 'Number[]', 'Uint8Array' ], isDate: ['Date', 'StringArray', 'String[]', 'NumberArray', 'Number[]'], minDate: ['Date', 'StringArray', 'String[]', 'NumberArray', 'Number[]'], maxDate: ['Date', 'StringArray', 'String[]', 'NumberArray', 'Number[]'], isBoolean: ['Boolean', 'Array', 'Any[]', '[]'], hasProperties: ['Object', 'ObjectArray', 'Object[]'], enum: ['Any'], notNull: ['Any'], pattern: ['Any'], isUnique: ['Any'], required: ['Any'], isNull: ['Any'], min: ['Number', 'NumberArray', 'Number[]', 'Uint8Array'], max: ['Number', 'NumberArray', 'Number[]', 'Uint8Array'], isPositive: ['Number', 'NumberArray', 'Number[]', 'Uint8Array'], isNegative: ['Number', 'NumberArray', 'Number[]', 'Uint8Array'], isNumeric: ['NumberArray', 'Number[]', 'Number'], isInteger: ['Number', 'NumberArray', 'Number[]'], isFloat: ['Number', 'NumberArray', 'Number[]'], isEmail: ['String', 'StringArray', 'String[]'], isURL: ['String', 'String[]', 'StringArray'], isAlpha: ['String', 'String[]', 'StringArray'], isAlphanumeric: ['String', 'String[]', 'StringArray'], isIP: ['String', 'String[]', 'StringArray'], trim: ['String', 'String[]', 'StringArray'], lowercase: ['String', 'String[]', 'StringArray'], uppercase: ['String', 'String[]', 'StringArray'], isText: ['String', 'StringArray', 'String[]', 'Any[]', 'Array', '[]'], isNumber: [ 'Number', 'NumberArray', 'Number[]', 'String', 'StringArray', 'String[]', 'Any[]', 'Array', '[]' ] }; /** * Determines if a rule should be applied element-wise if the field value is an array. */ ruleShouldApplyElementWise(ruleName) { const elementWiseRules = new Set(['uppercase', 'isText', 'isNumber']); return elementWiseRules.has(ruleName); } /** * Validates a record against a nested validations object. * * @param record - The record to validate. * @param validations - The validations object. * @returns A ValidationResult containing a valid flag and error messages. */ validate(record, validations) { const errors = this.validateRecursively(record, validations, ''); return { valid: errors.length === 0, errors }; } /** * Recursively validates a record against the validations object. * * @param record - The current value from the record. * @param validations - The validations object for the current field. * @param path - The current field path (for error messages). * @returns An array of error messages. */ validateRecursively(record, validations, path) { let errors = []; if (validations.rules) { for (const ruleName in validations.rules) { const ruleValue = validations.rules[ruleName]; errors.push(...this.validateRule(ruleName, ruleValue, record, path)); } } if (record === undefined) { return errors; } for (const key in validations) { if (key === 'rules') continue; const currentPath = path ? `${path}.${key}` : key; if (Array.isArray(record)) { record.forEach((item, index) => { const nestedValue = item ? item[key] : undefined; errors.push(...this.validateRecursively(nestedValue, validations[key], `${path}[${index}].${key}`)); }); } else { const fieldValue = record ? record[key] : undefined; errors.push(...this.validateRecursively(fieldValue, validations[key], currentPath)); } } return errors; } /** * Validates a single rule against a field value. * * @param ruleName - The name of the rule (e.g., "required", "minLength", "isText", "isNumber"). * @param ruleValue - The value of the rule (e.g., true, 50, 3). * @param fieldValue - The value of the field from the record. * @param path - The path to the field (for error messages). * @returns An array of error messages for this rule. */ validateRule(ruleName, ruleValue, fieldValue, path) { if (fieldValue === undefined && ruleName !== 'required') { return []; } const errors = []; if (Array.isArray(fieldValue) && this.ruleShouldApplyElementWise(ruleName)) { fieldValue.forEach((item, index) => { errors.push(...this.validateRule(ruleName, ruleValue, item, `${path}[${index}]`)); }); return errors; } switch (ruleName) { case 'required': if (ruleValue === true && (fieldValue === null || fieldValue === undefined)) { errors.push(`${path} is required but is missing.`); } break; case 'minLength': if (typeof fieldValue === 'string' || Array.isArray(fieldValue)) { if (fieldValue.length < ruleValue) { errors.push(`${path} should have a minimum length of ${ruleValue}, but got ${fieldValue.length}.`); } } else { errors.push(`${path} is not a string or array, so minLength cannot be applied.`); } break; case 'maxLength': if (typeof fieldValue === 'string' || Array.isArray(fieldValue)) { if (fieldValue.length > ruleValue) { errors.push(`${path} should have a maximum length of ${ruleValue}, but got ${fieldValue.length}.`); } } else { errors.push(`${path} is not a string or array, so maxLength cannot be applied.`); } break; case 'uppercase': if (typeof fieldValue === 'string') { if (fieldValue !== fieldValue.toUpperCase()) { errors.push(`${path} should be uppercase.`); } } else { errors.push(`${path} is not a string, so uppercase cannot be applied.`); } break; case 'min': if (typeof fieldValue === 'number') { if (fieldValue < ruleValue) { errors.push(`${path} should be at least ${ruleValue}, but got ${fieldValue}.`); } } else { errors.push(`${path} is not a number, so min cannot be applied.`); } break; case 'max': if (typeof fieldValue === 'number') { if (fieldValue > ruleValue) { errors.push(`${path} should be at most ${ruleValue}, but got ${fieldValue}.`); } } else { errors.push(`${path} is not a number, so max cannot be applied.`); } break; case 'isUnique': if (Array.isArray(fieldValue)) { const set = new Set(fieldValue); if (set.size !== fieldValue.length) { errors.push(`${path} should contain unique values.`); } } else { errors.push(`${path} is not an array, so isUnique cannot be applied.`); } break; case 'isText': if (typeof fieldValue === 'string') { const textRegex = /^[\p{L}\s]+$/u; if (!textRegex.test(fieldValue)) { errors.push(`${path} should contain only text (letters and spaces), but got "${fieldValue}".`); } } else { errors.push(`${path} is not a string, so isText cannot be applied.`); } break; case 'isNumber': if (typeof fieldValue === 'number') { } else if (typeof fieldValue === 'string') { const num = Number(fieldValue); if (Number.isNaN(num)) { errors.push(`${path} should represent a valid number, but got "${fieldValue}".`); } } else { errors.push(`${path} is neither a number nor a string, so isNumber cannot be applied.`); } break; default: errors.push(`Validation rule '${ruleName}' is not implemented at ${path}.`); } return errors; } } //# sourceMappingURL=validator.js.map