UNPKG

@justlep/camo

Version:

A class-based Object-Document Mapper (ODM) for NeDB

380 lines (338 loc) 13.1 kB
import fs from 'node:fs'; import path from 'node:path'; import {DatabaseClient} from './client.js'; import {hasOwnProp} from './util.js'; import {isArray} from './validate.js'; const NON_ID_CHAR_REGEX = /[^0-9a-z]/i; /** @type {typeof Datastore} - the datastore class defined during {@link connect} */ let Datastore; /** * Connect to current database * * @param {String} url - example: 'nedb://path/to/file/folder' or 'nedb://memory' * @param {typeof Datastore} datastoreClass - an Nedb `Datastore` class to use; * can origin from the original 'nedb' package or a fork like '@justlep/nedb' * @returns {Promise<NeDbClient>} */ export async function connect(url, datastoreClass) { // brief duck-typing of the given Datastore class should suffice if (typeof datastoreClass !== 'function' || typeof datastoreClass.prototype.loadDatabase !== 'function') { throw new Error('Invalid NeDB Datastore class'); } if (Datastore && datastoreClass !== Datastore) { throw new Error('Cannot change NeDB Datastore class after first connect'); } Datastore = datastoreClass; return new NeDbClient(url); } class NeDbClient extends DatabaseClient { /** * @param {string} url - 'nedb://memory' for in-memory, otherwise 'nedb:///path/to/dbFileFolder' */ constructor(url) { super(url); let pathOrMemory = url?.startsWith('nedb://') ? url.substr(7).trim() : null; if (!pathOrMemory) { throw new Error('Unrecognized DB connection url. Expected nedb://memory or nedb:///path/to/dbfiles'); } this._inMemory = pathOrMemory === 'memory'; this._path = this._inMemory ? null : pathOrMemory; this._collections = Object.create(null); } /** * @param {string} name - the collection name * @return {string} - the path to the .db file */ _getDbFilePath(name) { return this._inMemory ? name : path.join(this._path, name + '.db'); } /** * @param {string} name - the collection name * @return {Datastore} */ _getDb(name) { if (!hasOwnProp(this._collections, name)) { let collection; if (this._inMemory) { collection = new Datastore({inMemoryOnly: true}); } else { collection = new Datastore({ filename: this._getDbFilePath(name), autoload: true }); } this._collections[name] = collection; } return this._collections[name]; } /** * Save (upsert) document * * @param {String} collection Collection's name * @param {ObjectId?} id Document's id * @param {Object} values Data for save * @returns {Promise} Promise with result insert or update query */ save(collection, id, values) { return new Promise((resolve, reject) => { const db = this._getDb(collection); // TODO: I'd like to just use update with upsert:true, but I'm // note sure how the query will work if id == null. Seemed to // have some problems before with passing null ids. if (id === null) { db.insert(values, (error, result) => error ? reject(error) : resolve(result._id)); } else { db.update({_id: id}, {$set: values}, {upsert: true}, (error, result) => error ? reject(error) : resolve(result)); } }); } /** * Delete document * * @param {String} collection Collection's name * @param {?string} id Document's id * @returns {Promise<number>} number of deleted documents */ delete(collection, id) { if (id === null || id === undefined) { return Promise.resolve(0); } return new Promise((resolve, reject) => { const db = this._getDb(collection); db.remove({_id: id}, (error, numRemoved) => error ? reject(error) : resolve(numRemoved)); }); } /** * Delete one document by query * * @param {String} collection Collection's name * @param {Object} query Query * @returns {Promise<number>} number of deleted documents */ deleteOne(collection, query) { return new Promise((resolve, reject) => { const db = this._getDb(collection); db.remove(query, (error, numRemoved) => error ? reject(error) : resolve(numRemoved)); }); } /** * Delete many documents by query * * @param {String} collection Collection's name * @param {Object} query Query * @returns {Promise<number>} number of deleted documents */ deleteMany(collection, query) { return new Promise((resolve, reject) => { const db = this._getDb(collection); db.remove(query, {multi: true}, (error, numRemoved) => error ? reject(error) : resolve(numRemoved)); }); } /** * Find one document * * @param {String} collection Collection's name * @param {Object} query Query * @returns {Promise<?Document>} */ findOne(collection, query) { return new Promise((resolve, reject) => { const db = this._getDb(collection); db.findOne(query, (error, result) => error ? reject(error) : resolve(result)); }); } /** * Find one document and update it * * @param {String} collection Collection's name * @param {Object} query Query * @param {Object} values * @param {Object} options * @returns {Promise<?Document>} document that was updated (or upserted), null if none found or upserted */ findOneAndUpdate(collection, query, values, options) { if (!options) { options = {}; } // Since this is 'findOne...' we'll only allow user to update // one document at a time options.multi = false; return new Promise((resolve, reject) => { const db = this._getDb(collection); // TODO: Would like to just use 'Collection.update' here, but // it doesn't return objects on update (but will on insert)... /*db.update(query, values, options, function(error, numReplaced, newDoc) { if (error) return reject(error); resolve(newDoc); });*/ this.findOne(collection, query).then(data => { if (!data) { if (options.upsert) { return db.insert(values, (error, result) => error ? reject(error) : resolve(result)); } return resolve(null); } db.update(query, {[options.unset ? '$unset' : '$set']: values}, (error, result) => { if (error) { return reject(error); } // Fixes issue #55. Remove when NeDB is updated to v1.8+ // ^-- Nedb 1.8.0 still won't pass documents to update() callbacks db.findOne({_id: data._id}, (error, doc) => error ? reject(error) : resolve(doc)); }); }); }); } /** * Find one document and delete it * * @param {String} collection Collection's name * @param {Object} query Query * @param {Object} options * @returns {Promise<number>} number of removed documents */ findOneAndDelete(collection, query, options) { if (!options) { options = {}; } // Since this is 'findOne...' we'll only allow user to update // one document at a time options.multi = false; return new Promise((resolve, reject) => { const db = this._getDb(collection); db.remove(query, options, (error, numRemoved) => error ? reject(error) : resolve(numRemoved)); }); } /** * Find documents * * @param {String} collection Collection's name * @param {Object} query Query * @param {Object} options * @returns {Promise} */ find(collection, query, options = {}) { return new Promise((resolve, reject) => { let cursor = this._getDb(collection).find(query); let {sort, skip, limit} = options; if (sort && (isArray(sort) || typeof sort === 'string')) { let sortOptions = Object.create(null); for (let s of isArray(sort) ? sort : [sort]) { if (typeof s !== 'string') { continue; } if (s[0] === '-') { sortOptions[s.substring(1)] = -1; } else { sortOptions[s] = 1; } } cursor = cursor.sort(sortOptions); } if (typeof skip === 'number' && Number.isFinite(skip) && skip > 0) { cursor = cursor.skip(skip); } if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) { cursor = cursor.limit(limit); } cursor.exec((error, result) => error ? reject(error) : resolve(result)); }); } /** * Get count of collection by query * * @param {String} collection Collection's name * @param {Object} query Query * @returns {Promise<number>} # of matching documents */ count(collection, query) { return new Promise((resolve, reject) => { this._getDb(collection).count(query, (error, count) => error ? reject(error) : resolve(count)); }); } /** * Create index * * @param {String} collection Collection's name * @param {String} field Field name * @param {Object} options Options * @returns {Promise} */ createIndex(collection, field, options) { options = options || {}; options.unique = options.unique || false; options.sparse = options.sparse || false; this._getDb(collection).ensureIndex({fieldName: field, unique: options.unique, sparse: options.sparse}); } /** * Close current connection * * @returns {Promise} */ close() { // Nothing to do for NeDB } /** * Drop collection * * @param {String} collection * @returns {Promise<number>} - number of deleted documents */ clearCollection(collection) { return this.deleteMany(collection, {}); } /** * Drop current database. * (!) Behavior for persistent NeDBs is inconsistent since NeDB's async executor might still * be about to write some $$indexCreated info to a .db file while we are deleting that very file here. * That's why _dropDatabase() is now marked @internal, to be used for testing only; * Also, in tests, make sure to await-save some data if creating Document instances of schemas * that include unique indexes; otherwise tests may fail erratically. * * @returns {Promise} * @internal */ _dropDatabase() { if (this._inMemory) { Object.keys(this._collections).forEach(key => delete this._collections[key]); return Promise.resolve(); } return Promise.all(Object.keys(this._collections).map(collectionName => new Promise((resolve, reject) => { let dbFilePath = this._getDbFilePath(collectionName); // Delete the file, but only if it exists fs.stat(dbFilePath, (err, stat) => { if (!err) { if (stat && !stat.isFile()) { return reject(`Cannot drop collection, path is not a file: ${dbFilePath}`); } fs.unlink(dbFilePath, (err) => { if (err) { return reject(err); } delete this._collections[collectionName]; resolve(); }); } else { resolve(); } }); }))); } toCanonicalId(id) { return id; } /** * @param {*} value * @return {boolean} * @override */ isNativeId(value) { return typeof value === 'string' && value.length === 16 && !NON_ID_CHAR_REGEX.test(value); } nativeIdType() { return String; } driver() { return this._collections; } }