UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

820 lines (756 loc) 22.7 kB
/** * Structures access to the embedded [NeDB database](https://www.npmjs.com/package/nedb) * * extends mongo.js collection and document class to allow nedb and mongodb to be interchangeable * * this module (unlike the mongo module) does not provide a client variable! * * @requires nedb-promises * @requires events * * @requires mongo * @requires file * * @module ne * @copyright Lasse Marburg 2021 * @license MIT */ var NeDB = require("nedb-promises") const events = require("events") const mongo = require("./mongo.js") const file = require("./file.js") const path_module = require("path") /** nedb data directory * @default */ var path = "./data/nedb" /** * get nedb data path from config and create nedb data directory */ function start(config) { return new Promise((resolve, reject) => { if (adaptor.data) { path = path_module.join(adaptor.data, "/nedb") } if (!file.exists(path)) { log.info("NeDB", "Create data directory: " + path) file.mkdir(path) } log.info("NeDB", "Accessing Data files in " + path) return resolve("started") }) } /** * abstract function. No need to stop NeDB */ function stop() {} /** * Allows access to a Database. * * For the NeDB module a Database is represented by a folder that contains data files for each collection * * @param {Object} config * @param {String} config.name - database ID * @param {number} [config.url] - host and port of mongoDB and replikas. Optional if start() function was called * */ class Database extends mongo.Database { constructor(config) { super(config) this.type = "nedb" this.path = path + "/" + this.name } /** * check if database folder already exists, then create collection objects of each file * else return that database is new */ connect() { return new Promise((resolve, reject) => { this.db = {} if (file.mkdir(this.path)) { file.ls(path + "/" + this.name).forEach((file) => { var connector = new NeDB({ filename: this.path + "/" + file }) this[file] = new Collection(file, connector, this.name) log.debug(this.name, "Created collection Object of: " + file) }) return resolve("connected") } else { return resolve("created") } }) } /** * Create a copy of an existing nedb database folder * * @param {string} copy_name - name of database copy * @returns {Promise<undefined>} */ async clone(copy_name) { await file.cp(this.path, path + "/" + copy_name) } /** * return all collections in this Database as a list of strings based on files in the database folder * * @returns {Promise} array of Collection names (string). */ listCollections() { return new Promise((resolve, reject) => { return resolve(file.ls(this.path)) }) } /** * adds NeDB connector and collection to Database object * * @param {String} name - new collections name * * @returns {Promise} forwards response when done */ addCollection(name) { return new Promise((resolve, reject) => { var connector = new NeDB({ filename: path + "/" + this.name + "/" + name }) connector.load() this[name] = new Collection(name, connector, this.name) log.debug(this.name, "Created collection Object of: " + name) return resolve("created") }) } /** * drop the collection, delete the reference * remove file with file.js rm() * * @param {String} name - to be dropped collections name * * @returns {Promise} forwards response when done */ dropCollection(name) { return new Promise((resolve, reject) => { this[name].c .remove({}, { multi: true }) .then((result) => { delete this[name] log.debug( this.name, "delete collection file " + this.path + "/" + name + " removed " + result + " documents" ) file.rm(this.path + "/" + name) return resolve(result) }) .catch((error) => { log.error(this.name, error) }) }) } /** * Drop Database (Delete folder and all files) */ drop() { let name = this.name if (file.rm(this.path)) { log.info(0, "Database " + name + " deleted") } else { log.error(0, "Failed to delete Database: " + name) } } /** * This is a stand in for the respective mongodb function. Most parameter values are forwarded unchanged. * * Only BigInt types are parsed to string since NeDB uses JSON.serialize to store data, which does not handle BigInt well. * * @param {*} value - value be converted if it is of type BigInt * @returns {*} converted or unchanged value */ toLong(value) { if (typeof value === "bigint") { value = value.toString() } return value } /** * check if string is a valid nedb _id of 16 alphanumeric characters * * @param {string} id - value to be tested * @returns {boolean} true if id is a valid nedb _id */ validId(id) { if (typeof id === "string") { return id.match(/^[a-zA-Z0-9]{16}$/) } return false } } /** * Collection Object represents and gives access to a NeDB file. Inherits a NeDB connector. * * @param {String} name - name of collection. Same as object instance name in Database Object * @param {Object} col - Collection Object created from db connection */ class Collection extends mongo.Collection { constructor(name, nedb_connector, database_name) { super(name, nedb_connector) this.event = new events.EventEmitter() this.event_subscription = false this.database = database_name } /** * @param {Object} query - json to identify Document * * @returns {Promise<Document>} easier access to first document in this collection the query returns * @throws {adaptor.NotFoundError} if document is not found */ async document(query) { let doc = new Document(query, this) await doc.setup() return doc } /** * start the simulation of a mongo change stream * dispatch change event on every (successful) update/set/insert function call * * @returns {Object} a mongodb Change stream (https://docs.mongodb.com/manual/changeStreams/) */ changes() { this.event_subscription = true return this.event } /** * Dispatch change event with additional information on the event type and data. */ dispatchChange(changes) { this.event.emit("change", changes) } /** * creates Change Event Object with basic data * * similar to mongo change event: * https://docs.mongodb.com/manual/reference/change-events/ * * Though the information about what data was updated may be inaccurate! * updateDescription is a simple copy of the update data Object * */ changeEvent(type) { return { operationType: type, fullDocument: {}, updateDescription: {}, ns: { db: this.database, coll: this.name }, documentKey: { _id: undefined }, updateDescription: { updatedFields: {}, removedFields: [] } } } /** * dispatch a notification about updates in the collection. * * ignore if never any module subscribed to the change event. * * @param {String} type - either update or remove * @param {Object} query - query of original update command * @param {Object} before - document before update command * @param {Object} after - document after update command * * @returns {Object} Object of "updated" fields and list of "removed" fields */ updateEvent(type, query, before, after) { if (!after) { return } let changes = this.changeEvent(type) changes.fullDocument = after changes.documentKey._id = after._id let diff = adaptor.changes(before, after) if (adaptor.isEmpty(diff.updated) && !diff.removed.length) { return } changes.updateDescription.updatedFields = diff.updated changes.updateDescription.removedFields = diff.removed if (this.event_subscription) { this.dispatchChange(changes) } return diff } /** * compare two _ids and return if they are equal * Dummy function to keep mongo and NeDB interchangeable * mongo creates _id Objects that can not be compared with == * * @param {String} idA - id to be checked against idB * @param {String} idB - id to be checked against idA * * @returns {boolean} true if ids are equal */ compareIDs(idA, idB) { return idA == idB } /** * Dummy function to keep mongo and NeDB interchangeable * * @param {string} id - id to be transformed * @returns {string} unchanged id string */ makeID(id) { return id } /** * check if string is a valid nedb _id of 16 alphanumeric characters * * @param {string} id - value to be tested * @returns {boolean} true if id is a valid nedb _id */ validId(id) { if (typeof id === "string") { return id.match(/^[a-zA-Z0-9]{16}$/) } return false } /** * create a new indexed field * * @param {string} field - property that shall become an index * @param {boolean} unique - set true if index is supposed to be restricted to unique values */ createIndex(field, unique) { this.c.ensureIndex({ fieldName: field, unique: unique }) } /** * Recursive function that iterates over query object and converts mongo db specific key/value pairs. * * - Convert value behind '$regex' and '$option' key to js RegExp * - Convert value behind '$date' key to Date Object * - Convert string that matches ISO Date standart to Date Object * * @param {*} obj - The query object * @returns {*} Modified or original query object. */ extendQuery(obj) { var key, val for (key in obj) { if (!obj.hasOwnProperty(key)) continue val = obj[key] switch (key) { case "$date": return new Date(val) case "$regex": case "$options": return new RegExp(obj.$regex, obj.$options) } if (typeof val === "object") { obj[key] = this.extendQuery(val) } else if (typeof val === "string") { if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.?\d{0,3}Z?$/.test(val)) { obj[key] = new Date(val) } } } return obj } /** * get list of Documents based on query * * @param {Object} query - check documents against query * @param {Object} options.project - specify what fields to return with mongo projection object * @param {Object} options.sort - sort documents by fields in direction (1 or -1) * @param {Object} options.limit - set maximum numbers of documents to return * @param {Object} options.skp - return from nth document only * * @returns {Promise} forwards an array of all found documents. Returns no rejection! */ find(query, { project, limit = 0, skip = 0, sort = {} } = {}) { return new Promise((resolve, reject) => { if (!query) { query = {} } if (typeof query !== "object") { reject( new adaptor.InvalidError( `NeDB failed to run find operation. Find query property is ${query} and of type ${typeof query}.` ) ) } let options = this.queryOptions(query, { project, limit, skip, sort }) if (!options.sort._id) { options.sort["_id"] = 1 } this.c .find(this.extendQuery(query), options.project) .skip(options.skip) .limit(options.limit) .sort(options.sort) .then((result) => { resolve(this.decode(result)) }) .catch((error) => { log.error(this.name, error) }) }) } /** * get specific element of Documents based on query. * * @param {Object} key - define key in documents you want to have returned * @param {Object} query - check documents against query * * @returns {Promise} forwards an array of all found document keys. No rejection! */ distinct(key, query) { return new Promise((resolve, reject) => { this.find(query) .then((result) => { if (!result.length) { return resolve([]) } let dist = result.map((r) => { return adaptor.getPath(r, key) }) if (!dist) { return resolve([]) } return resolve(dist) }) .catch((error) => { log.error(this.name, error) }) }) } /** * Counts documents based on query. * * @param {Object} query - check documents against query * * @returns {Promise} forwards the number of all found documents. */ count(query) { return new Promise((resolve, reject) => { this.c .count(this.extendQuery(query)) .then((result) => { return resolve(result) }) .catch((error) => { log.error(this.name, error) }) }) } pick(query, path, sub_query) { return new Promise((resolve, reject) => { this.distinct(path, query) .then((result) => { if (!result.length) { return resolve(undefined) } if (!Array.isArray(result[0])) { return resolve(undefined) } return this.temp(result[0]) }) .then((result) => { if (!result) { return resolve(undefined) } return result.find(sub_query) }) .then((result) => { if (!result) { return resolve({}) } if (!result.length) { return resolve({}) } return resolve(result[0]) }) .catch((error) => { log.error(this.name, error) }) }) } /** * create a temporary NeDatabase filled with a list of initial documents. * * @returns {Promise} NeDB connector */ temp(list) { return new Promise((resolve, reject) => { if (!Array.isArray(list)) { return reject("temp() needs an array as argument") } let temp_connector = NeDB.create() temp_connector .load() .then((result) => { temp_connector.insert(list, { multi: true }) }) .then((result) => { return resolve(temp_connector) }) .catch((error) => { return reject(error) }) }) } /** * add new document to collection * * @returns {Promise} data about the inserted document including _id property */ insert(data) { return new Promise((resolve, reject) => { this.c .insert(this.encode(data)) .then((res) => { if (res) { let changes = this.changeEvent("insert") changes.fullDocument = res changes.documentKey._id = res._id this.dispatchChange(changes) res["insertedId"] = res._id this.feedback({ insertedCount: 1 }) return resolve(res) } }) .catch((err) => { return reject(err) }) }) } /** * Replace the first Document returned by query. * For NeDB unlike mongodb, using update without any operators replaces the queried document by the given data. * * since it calls NeDB update function it dispatches an 'update', not a 'replace' change event like mongo does * change event properties provide information about what is different between the orinal and the replacing document. * * @param {Object} query - query what documents to replace * @param {data} data - json of how the replaced document looks like * @param {data} options - see replaceOne options: https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/ */ replace(query, data, options) { return new Promise((resolve, reject) => { this.update(query, data) .then((res) => { this.feedback(res) return resolve(res) }) .catch((err) => { log.error(this.name, new Error(err)) return reject(err) }) }) } /** * make an update on selected documents. * * buffer the original document to allow creating a detailed change event * * @param {Object} query - query what documents to update * @param {data} data - json of what is to be updated * @param {object} [options] - update options * @param {boolean} [options.multiple] - Set `true` to allow to update many. Set `false` to update one document only * * @returns {Promise} matchedCount - number of matched documents, modifiedCount - number of modified documents */ update(query, data, { multiple = true } = { multiple: true }) { return new Promise((resolve, reject) => { let before = [] let options if (!multiple) { options = { limit: 1 } } this.find(query, options) .then((result) => { before = result return this.c.update(this.extendQuery(query), this.encode(data), { multi: multiple, returnUpdatedDocs: true }) }) .then((result) => { let modified = 0 for (let i = 0; i < result.length; i++) { let changes = this.updateEvent( "update", query, before[i], result[i] ) if (changes) { modified++ } } return resolve({ modifiedCount: modified, matchedCount: before.length, original: before[0], new: result[0] }) }) .catch((err) => { return reject(err) }) }) } /** * Update one document and return the original or the new document. * * Returns original document by default. Use `{returnDocument: "after"}` option to return the resulting document. * * @param {Object} query - query what documents to update * @param {Object} data - update operations data * @param {Object} [options] - additional options for operation. * @param {boolean} [options.returnDocument="before"] - before: include original document in return value. after: include updated document in return value */ findOneAndUpdate(query, data, options) { return new Promise((resolve, reject) => { this.update(query, data) .then((result) => { if (options.returnDocument && options.returnDocument == "after") { return resolve(result["new"]) } return resolve(result["original"]) }) .catch((err) => { return reject(err) }) }) } /** * change existing document. Only objects mentioned in query are updated. * If you don't want to rewrite the object as a whole, use string dot notation * to specify the nested object * * prepends $set to updated data * * @param {Object} query - query what documents to update * @param {data} data - json of what is to be updated * * @returns {Promise} number of documents that where updated */ set(query, data) { return new Promise((resolve, reject) => { this.update(query, { $set: data }) .then((result) => { return resolve(result) }) .catch((err) => { log.error(err) return reject(err) }) }) } /** * delete documents * * @param {Object} query - documents to be deleted * @param {object} [options] - update options * @param {boolean} [options.multiple] - Set `true` to allow to delete many. Set `false` to delete one document only */ delete(query, options) { return new Promise((resolve, reject) => { this.slice(query, options) .then((result) => { return resolve(result.length) }) .catch((error) => { log.error(this.name, error) }) }) } /** * delete documents based on query and return them * * @param {Object} query - what entries to cut out * @param {object} [options] - update options * @param {boolean} [options.multiple] - Set `true` to allow to delete many. Set `false` to delete one document only * * @returns {Promise<array>} deleted documents as array */ slice(query, { multiple = true } = { multiple: true }) { return new Promise((resolve, reject) => { let docs = [] let options if (!multiple) { options = { limit: 1 } } this.find(query, options) .then((result) => { docs = result return this.c.remove(this.extendQuery(query), { multi: multiple }) }) .then((result) => { if (result) { let changes = this.changeEvent("delete") changes.documentKey._id = docs[0]._id this.dispatchChange(changes) } return resolve(docs) }) .catch((error) => { log.error(this.name, error) }) }) } /** * append item to array in object. Don't allow duplicates * * @param {Object} data - Array name/path and values to append * use string with dot notation for nested array (e.g.: {'object.array':{key:value}}) * @returns {Promise} matchedCount - number of matched documents, modifiedCount - number of modified documents */ push(query, data, options) { return new Promise((resolve, reject) => { let documents = 0 this.find(query) .then((result) => { documents = result return this.update(query, { $addToSet: data }, options) }) .then((res) => { resolve(res) }) .catch((err) => { log.error(this.name, err) }) }) } } /** * Narrows any query down to the same document. * * @param {ObjectId} query - query to address one single record in mongodb * @param {ne.Collection} collection - collection that inherits this Document * @throws {adaptor.NotFoundError} if document is not found */ class Document extends mongo.Document { constructor(query, collection) { super(query, collection) } } if (require.main === module) { database = new Database({ name: "testNeDB" }) database .connect() .then((result) => { database.db.update({ test: 0 }, { $set: { yes: 111 } }) }) .catch((error) => { console.log(error) }) } module.exports = { Database: Database, Colleciton: Collection, start: start, stop: stop }