nuvira
Version:
Nuvira Database. New Database format (Readable & Easy to use), (Inbuilt Schema & constraints & rules & relations).
508 lines • 20.5 kB
JavaScript
/**
* 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