const mongodb = require('mongodb');
/**
* Base class for data classes for json getter
*
* @see push/api/data/* for examples
*/
class Jsonable {
/**
* Constructor
*
* @param {object} data data object
*/
constructor(data = {}) {
this.setData(data);
}
/**
* Set data doing any decoding / transformations along the way
*
* @param {object} data data to set
*/
setData(data) {
this._data = Object.assign({}, data);
}
/**
* Update internal data doing any decoding / transformations along the way
*
* @param {object} update data update
*/
updateData(update) {
Object.assign(this._data, update);
}
/**
* Get new object containing all fields ready for sending to client side
*/
get json() {
let json = {};
Object.keys(this._data)
.filter(k => this._data[k] !== null && this._data[k] !== undefined)
.forEach(k => {
let v = this._data[k];
if (v instanceof Jsonable) {
json[k] = v.json;
}
else if (Array.isArray(v)) {
json[k] = v.map(x => x instanceof Jsonable ? x.json : x);
}
else if (typeof v === 'object') {
let ret = {};
for (let key in v) {
if (v[key] && v[key] instanceof Jsonable) {
ret[key] = v[key].json;
}
else {
ret[key] = v[key];
}
}
json[k] = ret;
}
else {
json[k] = v;
}
});
return json;
}
/**
* Compatibility layer for cache & standard node.js functions IPC
*
* @returns {string} json string
*/
toJSON() {
return this.json;
}
}
/**
* Validation class
*
* @see push/api/data/* for examples
*/
class Validatable extends Jsonable {
/**
* Class scheme
*/
static get scheme() {
throw new Error('Must be overridden');
}
/**
* Class schema per given data, allows schema variativity for given data (validates according to subclasses schemas using a discriminator field)
* @see push/api/data/trigger.js
*
* @returns {object} class schema by default
*/
static discriminator(/*data*/) {
return this.constructor.scheme;
}
/**
* Override Jsonable logic allowing only valid data to be saved
*/
get json() {
let json = {},
scheme = this.constructor.scheme;
Object.keys(scheme)
.filter(k => this._data[k] !== null && this._data[k] !== undefined)
.forEach(k => {
let v = this._data[k];
if (v instanceof Validatable) {
json[k] = v.json;
}
else if (Array.isArray(v) && v.filter(vv => vv instanceof Validatable).length === v.length) {
json[k] = v.map(vv => vv.json);
}
else if (scheme[k].type === 'Object' && Object.values(v).filter(vv => vv instanceof Validatable).length === Object.values(v).length) {
json[k] = {};
for (let kk in v) {
json[k][kk] = v[kk].json;
}
}
else {
let valid = require('./common').validateArgs({data: this._data[k]}, {data: scheme[k]});
if (valid) {
json[k] = valid.data;
}
}
});
return json;
}
/**
* Validate data
*
* @param {object} data data to validate
* @param {object} scheme optional scheme override
* @returns {object} common.validateArgs object with replaced by class instance: {errors: [], result: true, obj: Validatable}
*/
static validate(data, scheme) {
return require('./common').validateArgs(data, scheme || this.scheme, true);
}
/**
* Validate data
*
* @returns {String[]|undefined} array of string errors or undefined if validation passed
*/
validate() {
let ret = require('./common').validateArgs(this._data, this.constructor.scheme, true);
if (!ret.result) {
return ret.errors;
}
}
}
/**
* Base class for MongoDB-backed instances
*
* @see push/api/data/* for examples
*/
class Mongoable extends Validatable {
/**
* Must be overridden in subclasses to return collection name
*/
static get collection() {
throw new Error('Not implemented');
}
/**
* Getter for id as a string
*/
get id() {
return this._data._id ? this._data._id.toString() : undefined;
}
/**
* Getter for id as is
*/
get _id() {
return this._data._id;
}
/**
* Setter for _id
*
* @param {ObjectID} id ObjectID value
*/
set _id(id) {
if (id !== null && id !== undefined) {
this._data._id = id;
}
else {
delete this._data._id;
}
}
/**
* Find a record in db and map it to instance of this class
*
* @param {object|string} query query for findOne
* @returns {This|null} instance of this class if the record is found in database, null otherwise
*/
static async findOne(query) {
if (typeof query === 'string') {
query = {_id: require('./common').db.ObjectID(query)};
}
else if (query instanceof mongodb.ObjectId) {
query = {_id: query};
}
let data = await require('./common').db.collection(this.collection).findOne(query);
if (data) {
return new this(data);
}
else {
return null;
}
}
/**
* Refresh current instance with data from the database
*
* @returns {This|boolean} this instance of this class if the record is found in database, false otherwise
*/
async refresh() {
let data = await require('./common').db.collection(this.constructor.collection).findOne({_id: this._id});
if (data) {
this.setData(data);
return this;
}
else {
return false;
}
}
/**
* Find multiple records in db and map them to instances of this class
*
* @param {object|string} query query for find
* @returns {This[]} array of instances of this class if the records are found in the database, empty array otherwise
*/
static async findMany(query) {
let data = await require('./common').db.collection(this.collection).find(query).toArray();
if (data && data.length) {
let Constr = this;
return data.map(dt => new Constr(dt));
}
else {
return [];
}
}
/**
* Count records in collection
*
* @param {object} query query for count
* @returns {Number} count of records in collection satisfying the query
*/
static async count(query) {
return await require('./common').db.collection(this.collection).count(query);
}
/**
* Delete record from collection
*
* @param {object} query query for count
* @returns {Number} count of records in collection satisfying the query
*/
static async deleteOne(query) {
return await require('./common').db.collection(this.collection).deleteOne(query);
}
/**
* Pass current data to mongo's save
*/
async save() {
let json = this.json;
await require('./common').db.collection(this.constructor.collection).updateOne({_id: this._id}, {$set: json}, {upsert: true});
}
/**
* Run an update operation modifying this's _data
*
* @param {object|string} update query for an update
* @param {function} op op to run to modify state of this in case of success
*/
async update(update, op) {
await require('./common').db.collection(this.constructor.collection).updateOne({_id: this._id}, update);
return op(this);
}
/**
* Run an atomic update operation against `filter` with modifications in `update`, updating instance data with latest version in case of success
*
* @param {object|string} filter filter for an update
* @param {object|string} update modify for a findAndModify
* @returns {boolean} true in case of success, false otherwise
*/
static async findOneAndUpdate(filter, update) {
let data = await require('./common').db.collection(this.collection).findOneAndUpdate(filter, update, {returnDocument: 'after'});
if (data.ok) {
return new this(data.value);
}
return false;
}
/**
* Run an atomic update operation against `filter` with modifications in `update`, updating instance data with latest version in case of success
*
* @param {object|string} filter filter for an update
* @param {object|string} update modify for a findAndModify
* @returns {boolean} true in case of success, false otherwise
*/
async updateAtomically(filter, update) {
let data = await require('./common').db.collection(this.constructor.collection).findOneAndUpdate(filter, update, {returnDocument: 'after'});
if (data.ok) {
this.setData(data.value);
return this;
}
return false;
}
/**
* Simple batch insert object:
*
* let batch = Model.batchInsert(3);
* await batch.pushAsync({a: 1});
* await batch.pushAsync({a: 2});
* await batch.pushAsync({a: 3}); // <- insertMany here
* await batch.pushAsync({a: 4});
* ...
* await batch.flush(); // flush the rest
*
* --- or ---
*
* for (let n of [1, 2, 3]) {
* if (batch.pushSync({a: 1})) {
* await batch.flush();
* }
* }
*
* @param {number} batch batch size
* @returns {object} with 2 async methods: push(record) and flush()
*/
static batchInsert(batch = 10000) {
let buffer = [],
total = 0,
collection = require('./common').db.collection(this.collection);
return {
/**
* Get current batch buffer length
*/
get length() {
return buffer.length;
},
/**
* Get current batch buffer length
*/
get total() {
return total;
},
/**
* Async push, that is if we're ok with a promise per insert
*
* @param {object} record document to insert
*/
async pushAsync(record) {
total++;
buffer.push(record);
if (buffer.length >= batch) {
await this.flush();
}
},
/**
* Sync push, that is if we're NOT ok with a promise per insert
*
* @param {object} record document to insert
* @returns {boolean} true if buffer needs to be flush()ed
*/
pushSync(record) {
total++;
buffer.push(record);
return buffer.length >= batch;
},
/**
* Flush the buffer by inserting documents into collection
*
* @param {number[]} ignore_codes error codes to ignore
*/
async flush(ignore_codes = []) {
if (buffer.length) {
try {
let res = await collection.insertMany(buffer);
total -= (buffer.length - res.insertedCount);
buffer = [];
return res.insertedIds;
}
catch (e) {
if (e.result && e.result.result && e.result.result.insertedIds) {
total -= (buffer.length - e.result.insertedCount);
}
if (!e.code || ignore_codes.indexOf(e.code) === -1) {
throw e;
}
else {
buffer = [];
return e.result.result.insertedIds;
}
}
}
}
};
}
}
module.exports = { Jsonable, Validatable, Mongoable };