const uuidv4 = require('uuid/v4');
const EventEmitter = require('./events');
const Schema = require('./schema');
const Result = require('./result');
const checks = require('./checks');
const { isPlainObject, clone } = require('./helpers');
/**
* @extends EventEmitter
* @borrows Collection#find as Collection#where
*/
class Collection extends EventEmitter {
/**
* Creates a Collection instance.
*
* @param {string} name Name of the collection.
*/
constructor(name) {
super({
insert: [],
remove: [],
find: [],
update: [],
});
this._name = name;
this._schema;
this._documents = [];
this._created = new Date().toISOString();
}
/**
* Insert new documents into the collection.
*
* @param {...object} docs Documents to insert. Passing an array of documents is also supported.
* @fires Collection#insert After successfully inserting a document.
* @returns {string[]} IDs of the inserted documents.
*/
insert(...docs) {
if (docs.length === 0) {
throw new Error('Document(-s) to insert cannot be empty');
}
if (docs.length === 1 && Array.isArray(docs[0])) {
docs = [].concat(docs[0]);
}
const ids = [];
for (const doc of docs) {
ids.push(this.insertOne(doc, false));
}
this.emit('insert', docs, this.documents);
return ids;
}
/**
* Insert a single document into the collection.
*
* @param {object} doc Document to insert.
* @param {boolean} [emitEvent=true] Whether to emit the *insert* event.
* @fires Collection#insert After successfully inserting a document if `emitEvent` is *true*.
* @returns {string} ID of the inserted document.
*/
insertOne(doc, emitEvent = true) {
if (!isPlainObject(doc)) {
throw new TypeError('Expected parameter `doc` to be a plain object');
}
if (this._schema instanceof Schema) {
const isValid = this._schema.validate(doc);
if (!isValid) {
throw new Error('Failed to validate document');
}
}
const id = uuidv4();
this._documents.push({
...doc,
__id: id,
});
if (emitEvent === true) {
this.emit('insert', [doc], this.documents);
}
return id;
}
/**
* Find all documents in the collection that match the query or pass the filter function.
*
* @param {object | function} filter Query object or filter function which will be applied on each document.
* @param {boolean} resultInstance Whether to return a Result instance with the matching documents.
* @fires Collection#find After successfully filtering documents.
* @returns {object[] | Result} Matching documents.
* @example
* // Find all documents where the value of the property price is greater than or equal to 50.
* collection.find({ price: { greaterThanOrEqual: 50 } });
* @example
* // Same result but using a function.
* collection.find((doc) => doc.price >= 50);
*/
find(filter, resultInstance = false) {
if (!isPlainObject(filter) && typeof filter !== 'function') {
throw new TypeError('Expected parameter `filter` to be a plain object or function');
}
if (isPlainObject(filter) && checks.hasUnknownCheck(filter)) {
throw new Error('At least one or more unknown query functions');
}
const filteredDocs = this.documents.filter((doc) => {
return this._checkDocument(doc, filter);
});
const result = resultInstance === true
? new Result(filteredDocs)
: filteredDocs;
this.emit('find', filteredDocs, this.documents);
return result;
}
/**
* Find the first document in the collection that matches the query or passes the filter function.
*
* @param {object | function} filter Query object or filter function which will be applied on each document.
* @fires Collection#find After successfully filtering documents.
* @returns {object} Matching document.
* @example
* // Find the first document where the value of the roperty price is greater than or equal to 50.
* collection.findOne({ price: { greaterThanOrEqual: 50 } });
* @example
* // Same result but using a function.
* collection.findOne((doc) => doc.price >= 50);
*/
findOne(filter) {
if (!isPlainObject(filter) && typeof filter !== 'function') {
throw new TypeError('Expected parameter `filter` to be a plain object or function');
}
if (isPlainObject(filter) && checks.hasUnknownCheck(filter)) {
throw new Error('At least one or more unknown query functions');
}
const result = this.documents.find((doc) => {
return this._checkDocument(doc, filter);
});
this.emit('find', [result], this.documents);
return result;
}
/**
* Find the document with the matching ID.
*
* @param {string} id ID of the document to retrieve.
* @returns {object} Matching document.
* @example
* collection.findById('1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed');
*/
findById(id) {
if (typeof id !== 'string') {
throw new TypeError('Expected parameter `id` to be a string');
}
const result = this.documents.find((doc) => doc.__id === id);
this.emit('find', [result], this.documents);
return result;
}
/**
* Remove all documents matching the given document from the collection.
*
* @param {object} doc Document(-s) to remove.
* @param {boolean} [first=false] Whether to remove only the first matching document.
* @fires Collection#remove After successfully removing the document(-s).
* @returns {object | object[]} Removed document(-s).
* @example
* // Remove the first matching document.
* collection.removeExact({ price: 50 }, true);
* @example
* // Remove all matching documents.
* collection.removeExact({ price: 50 });
*/
removeExact(doc, first = false) {
if (!isPlainObject(doc)) {
throw new TypeError('Expected parameter `doc` to be a plain object');
}
const removed = [];
const indices = [];
this.documents.forEach((d, idx) => {
// Shallow clone to remove the __id field for a deep strict equal check.
const _doc = { ...d };
delete _doc.__id;
const match = checks.deepStrictEqual(_doc, doc);
if (match) {
indices.push(idx);
}
});
if (indices.length > 0) {
if (first === true) {
removed.push(...this._documents.splice(indices[0], 1));
} else {
indices.reverse().forEach((idx) => {
removed.push(...this._documents.splice(idx, 1));
});
}
}
this.emit('remove', removed, this.documents);
return first === true ? removed[0] : removed;
}
/**
* Remove all documents in the collection that match the query or passes the filter function.
*
* @param {object | function} filter Query object or filter function which will be applied on each document.
* @fires Collection#remove After successfully removing the documents.
* @returns {object[]} Removed documents.
* @example
* // Remove all documents where the value of the
* // property price is greater than or equal to 50.
* collection.remove({ price: { greaterThanOrEqual: 50 } });
* @example
* // Same result but using a function.
* collection.remove((doc) => doc.price >= 50);
*/
remove(filter) {
if (!isPlainObject(filter) && typeof filter !== 'function') {
throw new TypeError('Expected parameter `filter` to be a plain object or function');
}
if (isPlainObject(filter) && checks.hasUnknownCheck(filter)) {
throw new Error('At least one or more unknown query functions');
}
const removed = [];
const indices = [];
this.documents.forEach((doc, idx) => {
if (this._checkDocument(doc, filter)) {
indices.push(idx);
}
});
// Reverse the array, so the actual indices of
// the documents won't change upon removing documents.
indices.reverse().forEach((idx) => {
removed.push(...this._documents.splice(idx, 1));
});
this.emit('remove', removed, this.documents);
return removed;
}
/**
* Remove the first document in the collection that matches the query or passes the filter function.
*
* @param {object | function} filter Query object or filter function which will be applied on each document.
* @fires Collection#remove After successfully removing the document.
* @returns {object[]} Removed document.
* @example
* // Remove the first document where the value of the
* // property price is greater than or equal to 50.
* collection.removeOne({ price: { greaterThanOrEqual: 50 } });
* // or
* collection.removeOne((doc) => doc.price >= 50);
*/
removeOne(filter) {
if (!isPlainObject(filter) && typeof filter !== 'function') {
throw new TypeError('Expected parameter `filter` to be a plain object or function');
}
if (isPlainObject(filter) && checks.hasUnknownCheck(filter)) {
throw new Error('At least one or more unknown query functions');
}
const idx = this.documents.findIndex((doc) => {
return this._checkDocument(doc, filter);
});
let removed;
if (idx !== -1) {
removed = this._documents.splice(idx, 1)[0];
}
this.emit('remove', removed, this.documents);
return removed;
}
/**
* Remove the document with the matching ID.
*
* @param {string} id ID of the document to remove.
* @fires Collection#remove After successfully removing the document.
* @returns {object} Removed document.
* @example
* collection.removeById('1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed');
*/
removeById(id) {
if (typeof id !== 'string') {
throw new TypeError('Expected parameter `id` to be a string');
}
const idx = this.documents.findIndex((doc) => doc.__id === id);
let removed;
if (idx !== -1) {
removed = this._documents.splice(idx, 1)[0];
}
this.emit('remove', removed, this.documents);
return removed;
}
/**
* Update all documents in the collection that match the query or pass the custom filter function.
*
* @param {object | function} filter Query object or filter function which will be applied on each document.
* @param {object | function} updater Object or update function which will be applied on each filtered document.
* @fires Collection#update After successfully updating the documents.
* @returns {object[]} Updated documents.
* @example
* // Update all documents where the value of the property price is greater than or equal to 50 and set it to 49.99.
* collection.update(
* (doc) => doc.price >= 50,
* { price: 49.99 }
* );
*/
update(filter, updater) {
if (!isPlainObject(filter) && typeof filter !== 'function') {
throw new TypeError('Expected parameter `filter` to be a plain object or function');
}
// TODO: find better name than "updater"
if (!isPlainObject(updater) && typeof updater !== 'function') {
throw new TypeError('Expected parameter `update` to be a plain object or function');
}
if (isPlainObject(filter) && checks.hasUnknownCheck(filter)) {
throw new Error('At least one or more unknown query functions');
}
const updated = [];
this.documents.forEach((doc) => {
if (!this._checkDocument(doc, filter)) {
return;
}
this._maybeUpdate(doc, updater);
updated.push(doc);
});
this.emit('update', updated, this.documents);
return updated;
}
/**
* Update the document with the matching ID.
*
* @param {string} id ID of the document to update.
* @param {object | function} updater Object or update function which will be applied on each filtered document.
* @fires Collection#update After successfully updating the document.
* @returns {object} Updated document.
* @example
* collection.updateById(
* '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed',
* { price: 49.99 }
* );
*/
updateById(id, updater) {
if (typeof id !== 'string') {
throw new TypeError('Expected parameter `id` to be a string');
}
if (!isPlainObject(updater) && typeof updater !== 'function') {
throw new TypeError('Expected parameter `update` to be a plain object or function');
}
const doc = this.documents.find((doc) => doc.__id === id);
if (doc !== undefined) {
this._maybeUpdate(doc, updater);
}
this.emit('update', [doc], this.documents);
return doc;
}
/**
* Register a schema to validate documents inserted into the collection.
*
* @param {Schema|object} schema Schema to validate documents with.
* @returns {Schema} Registered schema.
*/
registerSchema(schema) {
if (schema instanceof Schema) {
this._schema = schema;
} else if (isPlainObject(schema)) {
this._schema = new Schema(schema);
} else {
throw new TypeError('Expected parameter `schema` to be a Schema instance or plain object');
}
return this._schema;
}
/**
* Unregister the schema which ultimately stops the
* collection from validating any further documents.
*
* @returns {Schema} Unregistered schema if there was a registered schema beforehand.
*/
unregisterSchema() {
const unregistered = this._schema;
this._schema = undefined;
return unregistered;
}
/**
* Clean the collection from invalid documents using
* the passed in or registered schema.
*
* @param {Schema} [schema] Schema instance to clean collection with.
* @returns {object[]} Removed documents.
*/
cleanWithSchema(schema) {
if (schema !== undefined && !(schema instanceof Schema)) {
throw new Error('Expected parameter `schema` to be a Schema instance');
} else if (!(this._schema instanceof Schema)) {
throw new Error('No schema registered');
}
return this.remove((doc) => !(schema || this._schema).validate(doc));
}
/**
* Clear the collection by deleting all of its documents.
*
* @returns {object[]} All of the removed documents.
*/
clear() {
return this._documents.splice(0, this.size);
}
/**
* Get all documents of the collection.
*
* @returns {object[]} All documents of the collection.
*/
get documents() {
return this._documents;
}
/**
* Get the name of the collection.
*
* @returns {string} Name of the collection.
*/
get name() {
return this._name;
}
/**
* Get the registered schema of the collection.
*
* @returns {Schema} Registered schema of the collection.
*/
get schema() {
return this._schema;
}
/**
* Get whether the collection has a schema registered.
*
* @returns {boolean} Whether the collection has a schema registered.
*/
get hasSchema() {
return this._schema instanceof Schema;
}
/**
* Get the date and time of creation for this collection.
*
* @returns {string} Date and time of creation.
*/
get created() {
return this._created;
}
/**
* Get the first document of the collection.
*
* @returns {object} First document of the collection.
*/
get firstDocument() {
return this._documents[0];
}
/**
* Get the last document of the collection.
*
* @returns {object} Last document of the collection.
*/
get lastDocument() {
return this._documents[this._documents.length-1];
}
/**
* Get the amount of documents of the collection.
*
* @returns {number} Amount of documents of the collection.
*/
get size() {
return this._documents.length;
}
/**
* Update document if it passes schema validation (if enabled).
*
* @private
* @param {object} doc Document to update.
* @param {object | function} updater Object or update function which might be applied on `doc`.
*/
_maybeUpdate(doc, updater) {
const _doc = clone(doc);
if (typeof updater === 'function') {
updater(_doc);
} else {
Object.assign(_doc, updater);
}
if (this.hasSchema) {
const isValid = this._schema.validate(_doc);
if (!isValid) {
throw new Error('Failed to validate document');
}
}
Object.assign(doc, _doc);
}
/**
* Check if a document matches the filter.
*
* @param {object} doc Document to check.
* @param {object | function} filter Filter to apply on `doc`.
*/
_checkDocument(doc, filter) {
return typeof filter === 'function'
? filter(doc)
: checks.applyQuery(filter, doc);
}
/**
* Return the necessary properties for the export.
*
* @private
* @return {object} Necessary collection properties.
*/
_export() {
const exported = {
name: this.name,
documents: this.documents,
created: this.created,
};
if (this._schema instanceof Schema) {
exported.schema = this._schema._export();
}
return exported;
}
}
Collection.prototype.where = Collection.prototype.find;
module.exports = Collection;