UNPKG

clancydb

Version:

ClancyDB is a lightweight, file-based NoSQL database inspired by MongoDB. It provides an easy-to-use API for storing, querying, and managing JSON-based data.

381 lines (348 loc) 15 kB
const fs = require('fs-extra'); const _ = require('lodash'); const AsyncLock = require('async-lock'); const Joi = require('joi'); class DB { constructor(filePath) { this.filePath = filePath; this.data = {}; this.lock = new AsyncLock(); this._loadData(); } _saveData() { this.lock.acquire('write', () => { fs.writeJSONSync(this.filePath, this.data, { spaces: 2 }); }); } _loadData() { if (fs.existsSync(this.filePath)) { this.data = fs.readJSONSync(this.filePath); } else { this.data = {}; this._saveData(); } } collection(name) { if (!this.data[name]) { this.data[name] = []; this._saveData(); } return new Collection(name, this); } model(name, fields) { const collection = this.collection(name); const schema = this._generateSchema(fields); return new Model(collection, schema); } _generateSchema(fields) { const schemaObject = {}; for (let field in fields) { const fieldDef = fields[field]; let joiField = Joi[fieldDef.type](); if (fieldDef.args) { fieldDef.args.forEach(arg => { if (arg === 'integer') { joiField = joiField.integer(); } else if (typeof arg === 'number') { joiField = joiField.min(arg); } }); } schemaObject[field] = fieldDef.required ? joiField.required() : joiField; } return Joi.object(schemaObject); } } class Collection { constructor(name, db) { this.name = name; this.db = db; this.indexes = {}; } createIndex(field) { this.indexes[field] = _.groupBy(this.db.data[this.name], field); } find(query = {}) { const results = this.db.data[this.name].filter(doc => { for (let [key, condition] of Object.entries(query)) { if (typeof condition === 'object' && !Array.isArray(condition)) { for (let [op, value] of Object.entries(condition)) { switch (op) { case '$gte': if (!(doc[key] >= value)) return false; break; case '$lte': if (!(doc[key] <= value)) return false; break; case '$gt': if (!(doc[key] > value)) return false; break; case '$lt': if (!(doc[key] < value)) return false; break; case '$eq': if (!(doc[key] === value)) return false; break; case '$ne': if (!(doc[key] !== value)) return false; break; case '$in': if (!value.includes(doc[key])) return false; break; case '$nin': if (value.includes(doc[key])) return false; break; case '$regex': if (!new RegExp(value).test(doc[key])) return false; break; default: throw new Error(`Unsupported operator: ${op}`); } } } else { if (doc[key] !== condition) return false; } } return true; }); return { data: results, update: (updates) => { let updatedCount = 0; this.db.data[this.name] = this.db.data[this.name].map(doc => { if (results.includes(doc)) { updatedCount++; return { ...doc, ...updates }; } return doc; }); if (updatedCount > 0) { this.db._saveData(); } return { matchedCount: results.length, modifiedCount: updatedCount }; } }; } insert(doc) { this.db.data[this.name].push(doc); this.db._saveData(); return doc; } update(query, update, options = { runValidators: true }) { let updatedCount = 0; this.db.data[this.name] = this.db.data[this.name].map(doc => { if (_.isMatch(doc, query)) { updatedCount++; return { ...doc, ...update }; } return doc; }); this.db._saveData(); return { matchedCount: updatedCount, modifiedCount: updatedCount }; } delete(query = {}) { const initialLength = this.db.data[this.name].length; this.db.data[this.name] = this.db.data[this.name].filter(doc => { for (let [key, condition] of Object.entries(query)) { if (typeof condition === 'object' && !Array.isArray(condition)) { for (let [op, value] of Object.entries(condition)) { switch (op) { case '$gte': if (doc[key] >= value) return false; break; case '$lte': if (doc[key] <= value) return false; break; case '$gt': if (doc[key] > value) return false; break; case '$lt': if (doc[key] < value) return false; break; case '$eq': if (doc[key] === value) return false; break; case '$ne': if (doc[key] !== value) return false; break; case '$in': if (value.includes(doc[key])) return false; break; case '$nin': if (!value.includes(doc[key])) return false; break; case '$regex': if (new RegExp(value).test(doc[key])) return false; break; default: throw new Error(`Unsupported operator: ${op}`); } } } else { if (doc[key] === condition) return false; } } return true; }); if (this.db.data[this.name].length !== initialLength) { this.db._saveData(); } return { deletedCount: initialLength - this.db.data[this.name].length }; } aggregate(pipeline) { let results = [...this.db.data[this.name]]; for (const stage of pipeline) { const [operator, params] = Object.entries(stage)[0]; switch (operator) { case '$match': results = results.filter(doc => { for (let [key, condition] of Object.entries(params)) { if (typeof condition === 'object' && !Array.isArray(condition)) { for (let [op, value] of Object.entries(condition)) { switch (op) { case '$gte': if (doc[key] < value) return false; break; case '$lte': if (doc[key] > value) return false; break; case '$gt': if (doc[key] <= value) return false; break; case '$lt': if (doc[key] >= value) return false; break; case '$eq': if (doc[key] !== value) return false; break; case '$ne': if (doc[key] === value) return false; break; case '$in': if (!value.includes(doc[key])) return false; break; case '$nin': if (value.includes(doc[key])) return false; break; case '$regex': if (!new RegExp(value).test(doc[key])) return false; break; default: throw new Error(`Unsupported operator: ${op}`); } } } else { if (doc[key] !== condition) return false; } } return true; }); break; case '$sort': results = _.orderBy(results, Object.keys(params), Object.values(params).map(v => (v > 0 ? 'asc' : 'desc'))); break; case '$group': results = Object.values(_.groupBy(results, params._id)).map(group => { const result = { _id: group[0][params._id] }; for (const [key, func] of Object.entries(params)) { if (key === '_id') continue; if (func.$sum) { if (func.$sum === 1) { result[key] = group.length; } else { result[key] = _.sumBy(group, doc => doc[func.$sum]); } } if (func.$avg) { result[key] = _.meanBy(group, func.$avg); } if (func.$min) { result[key] = _.minBy(group, func.$min); } if (func.$max) { result[key] = _.maxBy(group, func.$max); } if (func.$count) { result[key] = group.length; } } return result; }); break; case '$limit': results = results.slice(0, params); break; case '$skip': results = results.slice(params); break; case '$project': results = results.map(doc => { let projectedDoc = {}; for (let key in doc) { if (params[key] !== 0) { projectedDoc[key] = doc[key]; } } for (let key in params) { if (params[key] === 0) { delete projectedDoc[key]; } } return projectedDoc; }); break; case '$unwind': results = results.flatMap(doc => doc[params.path] || []); break; case '$lookup': results = results.map(doc => { const joinedDocs = this.db.data[params.from]; doc[params.as] = joinedDocs.filter(joinedDoc => joinedDoc[params.localField] === doc[params.foreignField]); return doc; }); break; case '$count': results = [{ count: results.length }]; break; default: throw new Error(`Unknown aggregation operator: ${operator}`); } } return results; } } class Model { constructor(collection, schema) { this.collection = collection; this.schema = schema; } validate(doc) { const { error } = this.schema.validate(doc); if (error) throw new Error(`Validation failed: ${error.message}`); } validateUpdate(update) { const updateSchema = this.schema.tailor('update'); const { error } = updateSchema.validate(update); if (error) throw new Error(`Validation failed: ${error.message}`); } insert(doc) { this.validate(doc); return this.collection.insert(doc); } update(query, update, options = { runValidators: true }) { if (options.runValidators) { this.validateUpdate(update); } return this.collection.update(query, update, options); } find(query) { return this.collection.find(query); } delete(query) { return this.collection.delete(query); } aggregate(pipeline) { return this.collection.aggregate(pipeline); } } module.exports = DB;