tero
Version:
tero is a JSON document Manager, with ACID complaint and backup recovery mechanism.
426 lines (425 loc) • 15.7 kB
JavaScript
export class SchemaValidator {
schemas = new Map();
setSchema(collectionName, schema) {
try {
// Validate the schema itself
this.validateSchemaDefinition(schema);
this.schemas.set(collectionName, schema);
}
catch (error) {
throw new Error(`Invalid schema definition: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
getSchema(collectionName) {
return this.schemas.get(collectionName);
}
removeSchema(collectionName) {
return this.schemas.delete(collectionName);
}
hasSchema(collectionName) {
return this.schemas.has(collectionName);
}
listSchemas() {
return Array.from(this.schemas.keys());
}
validateSchemaDefinition(schema) {
for (const [fieldName, fieldSchema] of Object.entries(schema)) {
if (!fieldName || typeof fieldName !== 'string') {
throw new Error('Field names must be non-empty strings');
}
if (!fieldSchema || typeof fieldSchema !== 'object') {
throw new Error(`Field '${fieldName}' must have a schema definition`);
}
if (!fieldSchema.type) {
throw new Error(`Field '${fieldName}' must have a type`);
}
const validTypes = ['string', 'number', 'boolean', 'object', 'array', 'date', 'any'];
if (!validTypes.includes(fieldSchema.type)) {
throw new Error(`Field '${fieldName}' has invalid type '${fieldSchema.type}'`);
}
// Validate format if specified
if (fieldSchema.format) {
const validFormats = ['email', 'url', 'uuid', 'date', 'time', 'datetime', 'phone', 'ip'];
if (!validFormats.includes(fieldSchema.format)) {
throw new Error(`Field '${fieldName}' has invalid format '${fieldSchema.format}'`);
}
}
// Validate min/max for numbers
if (fieldSchema.type === 'number' && fieldSchema.min !== undefined && fieldSchema.max !== undefined) {
if (fieldSchema.min > fieldSchema.max) {
throw new Error(`Field '${fieldName}' min value cannot be greater than max value`);
}
}
// Validate nested object schema
if (fieldSchema.type === 'object' && fieldSchema.properties) {
this.validateSchemaDefinition(fieldSchema.properties);
}
// Validate array items schema
if (fieldSchema.type === 'array' && fieldSchema.items) {
this.validateSchemaDefinition({ items: fieldSchema.items });
}
}
}
validate(collectionName, data) {
const schema = this.schemas.get(collectionName);
// If no schema is defined, validation passes
if (!schema) {
return { valid: true, errors: [], data };
}
try {
const errors = [];
const sanitizedData = this.validateAndSanitizeData(data, schema, '', errors);
return {
valid: errors.length === 0,
errors,
data: errors.length === 0 ? sanitizedData : data
};
}
catch (error) {
return {
valid: false,
errors: [{
field: 'root',
message: error instanceof Error ? error.message : 'Validation failed',
value: data
}],
data
};
}
}
validateAndSanitizeData(data, schema, path, errors) {
if (data === null || data === undefined) {
data = {};
}
if (typeof data !== 'object' || Array.isArray(data)) {
errors.push({
field: path || 'root',
message: 'Data must be an object',
value: data,
expected: 'object'
});
return data;
}
const result = {};
// Validate each field in the schema
for (const [fieldName, fieldSchema] of Object.entries(schema)) {
const fieldPath = path ? `${path}.${fieldName}` : fieldName;
const fieldValue = data[fieldName];
// Check if required field is missing
if (fieldSchema.required && (fieldValue === undefined || fieldValue === null)) {
// Use default value if available
if (fieldSchema.default !== undefined) {
result[fieldName] = fieldSchema.default;
continue;
}
errors.push({
field: fieldPath,
message: `Required field '${fieldName}' is missing`,
expected: fieldSchema.type
});
continue;
}
// Skip validation if field is not provided and not required
if (fieldValue === undefined || fieldValue === null) {
if (fieldSchema.default !== undefined) {
result[fieldName] = fieldSchema.default;
}
continue;
}
// Validate the field
const validatedValue = this.validateField(fieldValue, fieldSchema, fieldPath, errors);
if (validatedValue !== undefined) {
result[fieldName] = validatedValue;
}
}
// Copy over any additional fields not in schema (flexible schema)
for (const [key, value] of Object.entries(data)) {
if (!(key in schema)) {
result[key] = value;
}
}
return result;
}
validateField(value, fieldSchema, path, errors) {
// Type validation
if (!this.validateType(value, fieldSchema.type)) {
errors.push({
field: path,
message: `Expected ${fieldSchema.type} but got ${typeof value}`,
value,
expected: fieldSchema.type
});
return value;
}
// Convert and validate based on type
let processedValue = value;
switch (fieldSchema.type) {
case 'string':
processedValue = this.validateString(value, fieldSchema, path, errors);
break;
case 'number':
processedValue = this.validateNumber(value, fieldSchema, path, errors);
break;
case 'boolean':
processedValue = this.validateBoolean(value, fieldSchema, path, errors);
break;
case 'date':
processedValue = this.validateDate(value, fieldSchema, path, errors);
break;
case 'array':
processedValue = this.validateArray(value, fieldSchema, path, errors);
break;
case 'object':
processedValue = this.validateObject(value, fieldSchema, path, errors);
break;
case 'any':
// No specific validation for 'any' type
break;
}
// Enum validation
if (fieldSchema.enum && !fieldSchema.enum.includes(processedValue)) {
errors.push({
field: path,
message: `Value must be one of: ${fieldSchema.enum.join(', ')}`,
value: processedValue,
expected: `enum: [${fieldSchema.enum.join(', ')}]`
});
}
// Custom validation
if (fieldSchema.custom) {
const customResult = fieldSchema.custom(processedValue);
if (customResult !== true) {
errors.push({
field: path,
message: typeof customResult === 'string' ? customResult : 'Custom validation failed',
value: processedValue
});
}
}
return processedValue;
}
validateType(value, expectedType) {
switch (expectedType) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
return typeof value === 'boolean';
case 'object':
return typeof value === 'object' && value !== null && !Array.isArray(value);
case 'array':
return Array.isArray(value);
case 'date':
return value instanceof Date || typeof value === 'string' || typeof value === 'number';
case 'any':
return true;
default:
return false;
}
}
validateString(value, schema, path, errors) {
// Length validation
if (schema.min !== undefined && value.length < schema.min) {
errors.push({
field: path,
message: `String must be at least ${schema.min} characters long`,
value,
expected: `min length: ${schema.min}`
});
}
if (schema.max !== undefined && value.length > schema.max) {
errors.push({
field: path,
message: `String must be at most ${schema.max} characters long`,
value,
expected: `max length: ${schema.max}`
});
}
// Format validation
if (schema.format) {
if (!this.validateFormat(value, schema.format)) {
errors.push({
field: path,
message: `Invalid ${schema.format} format`,
value,
expected: `valid ${schema.format}`
});
}
}
// Pattern validation
if (schema.pattern) {
const regex = new RegExp(schema.pattern);
if (!regex.test(value)) {
errors.push({
field: path,
message: `String does not match required pattern: ${schema.pattern}`,
value,
expected: `pattern: ${schema.pattern}`
});
}
}
return value;
}
validateNumber(value, schema, path, errors) {
// Range validation
if (schema.min !== undefined && value < schema.min) {
errors.push({
field: path,
message: `Number must be at least ${schema.min}`,
value,
expected: `min: ${schema.min}`
});
}
if (schema.max !== undefined && value > schema.max) {
errors.push({
field: path,
message: `Number must be at most ${schema.max}`,
value,
expected: `max: ${schema.max}`
});
}
return value;
}
validateBoolean(value, schema, path, errors) {
return value;
}
validateDate(value, schema, path, errors) {
let date;
if (value instanceof Date) {
date = value;
}
else if (typeof value === 'string' || typeof value === 'number') {
date = new Date(value);
}
else {
errors.push({
field: path,
message: 'Invalid date value',
value,
expected: 'Date, string, or number'
});
return value;
}
if (isNaN(date.getTime())) {
errors.push({
field: path,
message: 'Invalid date value',
value,
expected: 'valid date'
});
return value;
}
return date;
}
validateArray(value, schema, path, errors) {
if (!Array.isArray(value)) {
errors.push({
field: path,
message: 'Expected array',
value,
expected: 'array'
});
return value;
}
// Length validation
if (schema.min !== undefined && value.length < schema.min) {
errors.push({
field: path,
message: `Array must have at least ${schema.min} items`,
value,
expected: `min length: ${schema.min}`
});
}
if (schema.max !== undefined && value.length > schema.max) {
errors.push({
field: path,
message: `Array must have at most ${schema.max} items`,
value,
expected: `max length: ${schema.max}`
});
}
// Validate array items
if (schema.items) {
const validatedArray = value.map((item, index) => {
const itemPath = `${path}[${index}]`;
return this.validateField(item, schema.items, itemPath, errors);
});
return validatedArray;
}
return value;
}
validateObject(value, schema, path, errors) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
errors.push({
field: path,
message: 'Expected object',
value,
expected: 'object'
});
return value;
}
// Validate nested object properties
if (schema.properties) {
return this.validateAndSanitizeData(value, schema.properties, path, errors);
}
return value;
}
validateFormat(value, format) {
switch (format) {
case 'email':
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
case 'url':
try {
new URL(value);
return true;
}
catch {
return false;
}
case 'uuid':
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
case 'date':
return /^\d{4}-\d{2}-\d{2}$/.test(value) && !isNaN(Date.parse(value));
case 'time':
return /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/.test(value);
case 'datetime':
return !isNaN(Date.parse(value));
case 'phone':
return /^[\+]?[1-9][\d]{0,15}$/.test(value.replace(/[\s\-\(\)]/g, ''));
case 'ip':
return /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(value) ||
/^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/.test(value);
default:
return true;
}
}
// Utility methods
getSchemaStats() {
const schemaNames = Array.from(this.schemas.keys());
let totalFields = 0;
for (const schema of this.schemas.values()) {
totalFields += Object.keys(schema).length;
}
return {
totalSchemas: this.schemas.size,
schemaNames,
totalFields
};
}
exportSchemas() {
const exported = {};
for (const [name, schema] of this.schemas.entries()) {
exported[name] = { ...schema };
}
return exported;
}
importSchemas(schemas) {
for (const [name, schema] of Object.entries(schemas)) {
this.setSchema(name, schema);
}
}
clearAllSchemas() {
this.schemas.clear();
}
}