nuvira
Version:
Nuvira Database. New Database format (Readable & Easy to use), (Inbuilt Schema & constraints & rules & relations).
617 lines (558 loc) • 28 kB
text/typescript
import * as fs from 'fs';
import * as readline from 'readline';
import { NuviraSchema } from './extends/parseSchema';
import { NuviraValidation } from './extends/parseValidation';
import { NuviraRecords } from './extends/parseRecords';
import { ParsingMetadata, AllowedTypes, ParsedResult, ParserConfig, Document, SchemaType } from './types/general';
import { NuviraRelations } from './extends/parseRelations';
import { SchemaValidator, ValidationRulesValidator, ValidateDataParams, ValidateDataResult, ValidationResult } from './extends/validator';
/**
* Represents the main class for handling Nuvira data parsing, validation, and conversion.
* It processes input files, validates them against defined rules, and tracks the parsing metadata.
*
* @class Nuvira
* @param {ParserConfig} config - Configuration for parsing Nuvira data.
* @param {string} config.filePath - Path to the Nuvira file to parse.
* @param {('schema' | 'validations' | 'records')} [config.section] - Optional section to focus on during parsing.
*/
export class Nuvira {
private section?: 'schema' | 'relations' | 'records' | 'validations';
private filePath?: string;
private fileContent?: string;
private parsingStartTime: number;
private sectionStartTime: number;
private metadata: ParsingMetadata;
lines: string[];
position: number;
private relations: Record<string, any> = {};
parsedSchema: Record<string, any>;
validations: Record<string, any> = {};
records: Document[];
allowedTypes: string[];
validationKeywords: Record<string, AllowedTypes[]>;
errors: { line: number | null; message: string }[];
sectionOrder: string[];
fileRules: { Strict: boolean, schemaName: string; Size: number, Locked: boolean; Type: SchemaType };
MAX_ERRORS: number;
/**
* Constructs an instance of the Nuvira parser and initializes its properties.
*
* @param {ParserConfig} config - The configuration object for parsing the file.
* @param {string} config.filePath - Path to the Nuvira file that will be parsed.
* @param {('schema' | 'validations' | 'records')} [config.section] - The specific section to focus on during parsing (optional).
*/
constructor({ filePath, section, fileContent }: ParserConfig) {
if (!filePath && !fileContent) {
throw new Error(
"Invalid configuration: At least one of 'filePath' or 'fileContent' must be provided."
);
}
if (filePath) {
fs.promises.access(filePath, fs.constants.F_OK)
.catch(() => {
throw new Error(`File not found or not accessible: ${filePath}`);
});
}
this.filePath = filePath;
this.fileContent = fileContent;
this.section = section;
this.lines = [];
this.position = 0;
this.relations = {};
this.parsedSchema = {};
this.validations = {};
this.records = [];
this.MAX_ERRORS = 50;
this.allowedTypes = [
'Number', 'String', 'Binary', 'Date', 'Boolean', 'Uint8Array', 'Binary',
'Object', 'Any[]', 'StringArray', 'String[]', 'ObjectArray', 'NumberArray', 'Number[]',
'Number[]', 'String[]', 'Object[]', 'Null', 'undefined', 'Array',
'[]', 'Any', 'AnyArray',
];
this.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']
};
this.errors = [];
this.sectionOrder = [];
this.fileRules = { Strict: false, schemaName: 'unnamed-schema', Size: 10000, Locked: false, Type: 'ISOLATED' };
this.parsingStartTime = 0;
this.sectionStartTime = 0;
this.metadata = {
timeTaken: '0 seconds',
recordCount: 0,
schemaFieldCount: 0,
validationRuleCount: 0,
fileSize: '0 bytes',
averageRecordSize: '0 byetes',
timestamp: new Date().toLocaleString(),
memoryUsage: {
heapTotal: '0 MB',
heapUsed: '0 MB',
external: '0 MB',
},
sections: {
schema: { timeMs: 0 },
relations: { timeMs: 0 },
validations: { timeMs: 0 },
records: { timeMs: 0 }
}
};
}
/**
* Main method that handles the parsing of the Nuvira file, processes sections, and gathers metadata.
* It reads the file, processes its sections, and returns parsed results along with metadata.
*
* @async
* @returns {Promise<ParsedResult>} - A promise that resolves to the parsed results, including metadata and errors.
*/
async parse(): Promise<ParsedResult> {
const formatFileSize = (size: number): string => {
if (size < 1024) return `${size.toFixed(2)} bytes`;
if (size < 1048576) return `${(size / 1024).toFixed(2)} KB`;
if (size < 1073741824) return `${(size / 1048576).toFixed(2)} MB`;
return `${(size / 1073741824).toFixed(2)} GB`;
};
const formatTime = (ms: number): string => `${(ms / 1000).toFixed(2)} seconds`;
this.parsingStartTime = performance.now();
if (this.fileContent) {
this.lines = this.fileContent
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
} else if (this.filePath) {
const stats = await fs.promises.stat(this.filePath!);
this.metadata.fileSize = formatFileSize(stats.size);
const fileStream = fs.createReadStream(this.filePath!);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
if (line.trim().length > 0) this.lines.push(line.trim());
}
}
const result = this.parseLines();
const parsingEndTime = performance.now();
this.metadata.timeTaken = formatTime(parsingEndTime - this.parsingStartTime);
this.metadata.recordCount = this.records.length;
this.metadata.schemaFieldCount = Object.keys(this.parsedSchema).length;
this.metadata.validationRuleCount = Object.keys(this.validations).length;
this.metadata.averageRecordSize =
this.records.length > 0
? formatFileSize(this.lines.join('\n').length / this.records.length)
: '0 bytes';
const memoryUsage = process.memoryUsage();
this.metadata.memoryUsage = {
heapTotal: formatFileSize(memoryUsage.heapTotal),
heapUsed: formatFileSize(memoryUsage.heapUsed),
external: formatFileSize(memoryUsage.external),
};
return {
...result,
metadata: this.metadata,
};
}
private parseLines(): ParsedResult {
while (this.position < this.lines.length) {
const line = this.lines[this.position].trim();
if (line.startsWith('!#')) {
this.position++;
continue;
}
const configLines = line.split(/\s*(?=\*[^=]+=)/);
let lineProcessed = false;
configLines.forEach(configLine => {
configLine = configLine.trim();
if (configLine.startsWith("*STRICT=")) {
const strictValue = configLine.split("=")[1]?.trim().toUpperCase();
if (strictValue === "TRUE") {
if (this.section) throw new Error("Strict-Mode is enabled, please turn it off.");
this.fileRules.Strict = true;
} else if (strictValue === "FALSE") {
this.fileRules.Strict = false;
} else {
this.errors.push({
line: this.position + 1,
message: `Invalid *STRICT value: "${strictValue}" at line ${this.position + 1}: "${configLine}". Expected TRUE or FALSE.`,
});
}
lineProcessed = true;
}
if (configLine.startsWith("*SIZE=")) {
const sizeValue = configLine.split("=")[1]?.trim();
const sizeNumber = Number(sizeValue);
if (!Number.isInteger(sizeNumber) || sizeNumber <= 0) {
this.errors.push({
line: this.position + 1,
message: `Invalid *SIZE value: "${sizeValue}" at line ${this.position + 1}: "${configLine}". Expected a positive integer.`,
});
} else {
this.fileRules.Size = sizeNumber;
}
lineProcessed = true;
}
if (configLine.startsWith("*TYPE=")) {
const typeValue = configLine.split("=")[1]?.trim().toUpperCase();
const allowedTypesSet = new Set(["ROOT", "NODE", "LEAF", "ISOLATED", "REFERENCE"]);
if (!allowedTypesSet.has(typeValue)) {
this.errors.push({
line: this.position + 1,
message: `Invalid *TYPE value: "${typeValue}" at line ${this.position + 1}: "${configLine}". Allowed values are ${[...allowedTypesSet].join(", ")}.`,
});
} else {
this.fileRules.Type = typeValue as SchemaType;
}
lineProcessed = true;
}
if (configLine.startsWith("*LOCKED=")) {
const lockedValue = configLine.split("=")[1]?.trim().toUpperCase();
if (lockedValue === "TRUE") {
this.fileRules.Locked = true;
} else if (lockedValue === "FALSE") {
this.fileRules.Locked = false;
} else {
this.errors.push({
line: this.position + 1,
message: `Invalid *LOCKED value: "${lockedValue}" at line ${this.position + 1}: "${configLine}". Expected TRUE or FALSE.`,
});
}
lineProcessed = true;
}
});
if (lineProcessed) {
this.position++;
continue
}
if (line.startsWith("@schema")) {
if (this.section === "schema") {
this.position++;
this.parseSchema();
return {
fileRules: this.fileRules,
schema: this.parsedSchema,
validations: {},
relations: {},
records: [],
errors: this.errors,
};
} else if (!this.section) {
this.checkSectionOrder("@schema");
this.position++;
this.parseSchema();
}
} else {
switch (line) {
case "@relations":
if (this.section === "relations") {
this.position++;
this.parseRelations();
return {
fileRules: this.fileRules,
schema: {},
validations: {},
relations: this.relations,
records: [],
errors: this.errors,
};
} else if (!this.section) {
this.checkSectionOrder("@relations");
this.position++;
this.parseRelations();
}
break;
case "@validations":
if (this.section === "validations") {
this.position++;
this.parseValidation();
return {
fileRules: this.fileRules,
schema: {},
validations: this.validations,
relations: {},
records: [],
errors: this.errors,
};
} else if (!this.section) {
this.checkSectionOrder("@validations");
this.position++;
this.parseValidation();
}
break;
case "@records":
if (this.section === "records") {
this.position++;
this.parseRecords();
return {
fileRules: this.fileRules,
schema: {},
validations: {},
relations: {},
records: this.records,
errors: this.errors,
};
} else if (!this.section) {
this.checkSectionOrder("@records");
this.position++;
this.parseRecords();
}
break;
case "@end":
if (!this.section) {
if (this.sectionOrder.length === 0) {
this.errors.push({
line: this.position + 1,
message: `Unexpected '@end' without an open section.`,
});
} else {
const lastSection = this.sectionOrder.pop();
if (
lastSection !== "@schema" &&
lastSection !== "@relations" &&
lastSection !== "@validations" &&
lastSection !== "@records"
) {
this.errors.push({
line: this.position + 1,
message: `Unexpected '@end' for section: ${lastSection}.`,
});
}
}
}
break;
default:
if (this.section && this.section !== "records" && this.section !== "schema" && this.section !== "relations") {
throw new Error(`Invalid section parsing!`);
}
if (!this.section) {
this.errors.push({
line: this.position + 1,
message: `Unknown section or command: "${line}"`,
});
}
break;
}
}
this.position++;
}
if (!this.parsedSchema) {
this.errors.push({ line: null, message: `Missing required section: '@schema'` });
}
if (!this.records) {
this.errors.push({ line: null, message: `Missing required section: '@records'` });
}
return {
fileRules: this.fileRules,
schema: this.parsedSchema,
validations: this.validations,
relations: this.relations,
records: this.records,
errors: this.errors,
};
}
/**
* Checks and enforces the order of sections within the Nuvira file.
* Ensures that sections like `@schema`, `@relations`, `@validations`, and `@records` follow a specific order.
*
* @param {string} section - The section name that is being processed (e.g., '@schema', '@relations', '@validations', '@records').
*/
private checkSectionOrder(section: string): void {
if (section.startsWith("@schema")) {
if (this.sectionOrder.includes("@schema") || this.sectionOrder.some(s => s.startsWith("@schema:"))) {
this.errors.push({ line: this.position + 1, message: `'@schema' is already opened but not closed.` });
}
this.sectionOrder.push(section);
} else if (section === "@relations") {
if (!this.sectionOrder.includes("@schema") && !this.sectionOrder.some(s => s.startsWith("@schema:"))) {
this.errors.push({ line: this.position + 1, message: `'@relations' must come after '@schema'.` });
}
if (this.sectionOrder.includes("@relations")) {
this.errors.push({ line: this.position + 1, message: `'@relations' is already opened but not closed.` });
}
this.sectionOrder.push(section);
} else if (section === "@validations") {
if (!this.sectionOrder.includes("@schema") && !this.sectionOrder.some(s => s.startsWith("@schema:"))) {
this.errors.push({ line: this.position + 1, message: `'@validations' must come after '@schema'.` });
}
if (this.sectionOrder.includes("@relations") && !this.sectionOrder.includes("@relations")) {
this.errors.push({ line: this.position + 1, message: `'@validations' must come after '@relations'.` });
}
if (this.sectionOrder.includes("@validations")) {
this.errors.push({ line: this.position + 1, message: `'@validations' is already opened but not closed.` });
}
this.sectionOrder.push(section);
} else if (section === "@records") {
if (!this.sectionOrder.includes("@schema") && !this.sectionOrder.some(s => s.startsWith("@schema:"))) {
this.errors.push({ line: this.position + 1, message: `'@records' must come after '@schema'.` });
}
if (this.sectionOrder.includes("@validations") && !this.sectionOrder.includes("@validations")) {
this.errors.push({ line: this.position + 1, message: `'@records' must come after '@validations'.` });
}
if (this.sectionOrder.includes("@records")) {
this.errors.push({ line: this.position + 1, message: `'@records' is already opened but not closed.` });
}
this.sectionOrder.push(section);
}
}
/**
* Parses the `@schema` section of the Nuvira file.
* This method processes the schema lines, validates the schema fields, and populates the `parsedSchema` property.
*
* @returns {void} - No return value. Updates the `parsedSchema` and `errors` properties of the instance.
*/
private parseSchema(): void {
this.sectionStartTime = performance.now();
const schemaParser = new NuviraSchema({ lines: this.lines, position: this.position, allowedTypes: this.allowedTypes });
const results = schemaParser.parseSchema();
const schemaName = results.schemaName;
this.metadata.sections.schema.timeMs = performance.now() - this.sectionStartTime;
this.fileRules.schemaName = schemaName || 'unnamed_schema';
this.parsedSchema = results.parsedSchema;
this.errors.push(...results.errors.slice(0, this.MAX_ERRORS));
this.lines = results.lines;
this.position = results.position;
}
/**
* Parses the `@relations` section of the Nuvira file.
* This method processes the relation lines and populates the `relations` property.
*
* @returns {void} - No return value. Updates the `relations` and `errors` properties of the instance.
*/
private parseRelations(): void {
this.sectionStartTime = performance.now();
const relationsParser = new NuviraRelations({ lines: this.lines, position: this.position });
const results = relationsParser.parseRelations();
this.metadata.sections.relations.timeMs = performance.now() - this.sectionStartTime;
this.relations = results.relations;
this.errors.push(...results.errors.slice(0, this.MAX_ERRORS));
this.position = results.position;
}
/**
* Parses the `@validations` section of the Nuvira file.
* This method processes the validation rules, validates them against the schema, and populates the `validations` property.
*
* @returns {void} - No return value. Updates the `validations` and `errors` properties of the instance.
*/
private parseValidation(): void {
this.sectionStartTime = performance.now();
const validationParser = new NuviraValidation({
lines: this.lines,
position: this.position,
parsedSchema: this.parsedSchema,
validationKeywords: this.validationKeywords
});
const results = validationParser.parseValidation();
this.metadata.sections.validations.timeMs = performance.now() - this.sectionStartTime;
this.validations = results.validations;
this.errors.push(...results.errors.slice(0, this.MAX_ERRORS));
this.position = results.position;
}
/**
* Parses the `@records` section of the Nuvira file.
* This method processes the records and stores them in the `records` property.
*
* @returns {void} - No return value. Updates the `records` and `errors` properties of the instance.
*/
private parseRecords(): void {
this.sectionStartTime = performance.now();
const recordParser = new NuviraRecords(this.lines, this.position);
const results = recordParser.parseRecords(500);
this.metadata.sections.records.timeMs = performance.now() - this.sectionStartTime;
this.errors.push(...results.errors.slice(0, this.MAX_ERRORS));
this.records = results.records;
this.position = results.position;
}
/**
* Reprocesses and optionally updates the document by renumbering the records in the `@records` section.
* If `content` is provided, it will use that content, otherwise, it will read from the file.
*
* @async
* @param {string} [content] - Optional content to process. If not provided, the method will read from the file.
* @returns {Promise<string | void>} - Returns the updated content if `content` is provided, or writes the updates back to the file.
*/
async redoc(content?: string): Promise<string | void> {
try {
const fileContent = content ?? (await fs.promises.readFile(this.filePath!, 'utf8'));
const lines = fileContent.split('\n');
let inRecordsSection = false;
let newDocNumber = 0;
const updatedLines: string[] = [];
for (const line of lines) {
if (line.trim() === '@records') {
inRecordsSection = true;
updatedLines.push(line);
} else if (line.trim() === '@end' && inRecordsSection) {
inRecordsSection = false;
updatedLines.push(line);
} else if (inRecordsSection && line.trim().startsWith('#')) {
const updatedLine = line.replace(/^#\d+/, `#${newDocNumber}`);
updatedLines.push(updatedLine);
newDocNumber++;
} else {
updatedLines.push(line);
}
}
const updatedContent = updatedLines.join('\n');
if (content) {
return updatedContent;
} else {
await fs.promises.writeFile(this.filePath!, updatedContent, 'utf8');
}
} catch (error) {
console.error('Error fixing document numbers:', error);
throw error;
}
}
/**
* Validates data against an optional schema and validations rules.
*
* @param {ValidateDataParams} params - The parameters for validation.
* @param {{ [key: string]: Schema } | undefined} params.schema - An optional dictionary of schemas defining the data structure.
* @param {any} params.validations - Optional validations rules to check the data values.
* @param {any} params.data - The data (record) to validate.
* @returns {Promise<ValidateDataResult>} A promise that resolves to an object containing the results of schema and data validations.
* @throws {Error} Throws an error if data is not provided.
*/
async validateData({ schema, validations, data }: ValidateDataParams): Promise<ValidateDataResult> {
if (!data) {
throw new Error("Data is required for validation.");
}
let schemaValidation: ValidationResult | undefined;
let dataValidation: ValidationResult | undefined;
if (schema) {
const schemaValidator = new SchemaValidator(schema);
schemaValidation = schemaValidator.validate(data);
}
if (validations) {
const validationRulesValidator = new ValidationRulesValidator();
dataValidation = validationRulesValidator.validate(data, validations);
}
return { schemaValidation, dataValidation };
}
}