adaptorex
Version:
Connect all your live interactive storytelling devices and software
820 lines (756 loc) • 22.7 kB
JavaScript
/**
* 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
}