Piece.js

/* eslint-disable max-len */
const _ = require("lodash"),
	EventEmitter = require("events"),
	Err = require("./MuffinError");

const _closeCheck = Symbol("closeCheck"),
	_cacheDefer = Symbol("cacheDefer"),
	_cacheReady = Symbol("cacheReady"),
	_cacheFailed = Symbol("cacheFailed"),
	_typeCheck = Symbol("typeCheck");

class Piece extends EventEmitter {
	/**
	 * An object similar to Map that has an optional cache, used to interact with the database.
	 * @namespace
	 * @class
	 * @protected
	 * @description Initialize a new Piece. You need to use MuffinClient#piece or MuffinClient#multi to do that.
	 * @since 1.0
	 * @example
	 * const Muffin = require("muffindb");
	 * const client = new Muffin.Client(options);
	 * 
	 * const piece = client.piece("example", { fetchAll: true, cacheSyncAuto: true })
	 * @param {Collection} base - The [Collection]{@link https://mongodb.github.io/node-mongodb-native/3.3/api/Collection.html} from MongoDB.
	 * @param {MuffinClient} client - The client that instantiated the Piece.
	 * @param {PieceOptions} options - Actually there is only the fetchAll option.
	 */
	constructor(base, client, options) {
		super();
		/**
		 * @since 1.0
		 * @member {Collection} - The collection wrapped by the piece.
		 */
		this.base = base;

		/**
		 * @since 1.0
		 * @member {MuffinClient} - The client that instantiated the Piece.
		 */
		this.client = client;

		if (options) {
			/**
			 * @since 1.2
			 * @member {boolean} - If set to true, the cache is available.
			 */
			this.hasCache = true;

			/**
			 * @since 1.2
			 * @member {Map} - An optional cache, it can be useful but it can also uses a lot of ram.
			 */
			this.cache = new Map();

			/**
			 * @since 1.3
			 * @member {Promise} - If the fetchAll option has been set to true, this property will be true when all the data have been cached. Otherwise it is always true.
			 */
			this.isCacheReady = false;

			this[_cacheDefer] = new Promise((res, rej) => {
				try {
					if (this.isCacheReady) {
						res();
					}

					this[_cacheReady] = res;
					this[_cacheFailed] = rej;
				} catch (error) {
					rej(error);
				}
			});

			if (options.fetchAll) {
				(async () => {
					try {
						await this.base.find({}).forEach((d) => this.cache.set(d._id, d.value));

						this[_cacheReady]();
						this.isCacheReady = true;
					} catch (e) {
						this[_cacheFailed](e);
					}
				})();
			} else {
				this[_cacheReady]();
				this.isCacheReady = true;
			}
		}

		if (_.isNil(options.autoCacheSync)) {
			options.autoCacheSync = true;
		}

		/**
		 * @event Piece#change
		 * @since 1.2
		 * @description Emit when a change occurs on the database.
		 * @type {any}
		 */
		this.base.watch(null, { fullDocument: "updateLookup" }).on("change", async (obj) => {
			this.emit("change", obj);

			if (options.autoCacheSync) {
				await this[_cacheDefer];

				if (this.hasCache) {
					switch (obj.operationType) {
						case "update":
						case "insert":
						case "replace":
							this.cache.set(obj.fullDocument._id, obj.fullDocument.value);
							break;
						case "delete":
							this.cache.delete(obj.documentKey._id);
							break;
						case "drop":
						case "dropDatabase":
							this.cache.clear();
							break;
					}
				}
			}
		});
	}

	[_closeCheck]() {
		if (this.client.closed) {
			throw new Err("the database has been closed", "MuffinClosedError");
		}
	}

	[_typeCheck](key) {
		return ["Number", "String", "Object"].includes(key.constructor.name);
	}

	/**
	 * @async
	 * @description Sets a document into the database.
	 * @since 1.0
	 * @param {*} key - The key of the document to set.
	 * @param {*} val - The value of the document to set into the database.
	 * @param {string} [path=null] - The path to the property to modify inside the value. Can be a dot-separated path, such as "prop1.subprop2.subprop3".
	 * @example
	 * // Sets the value "bar" to the key "foo"
	 * await piece.set("foo", "bar");
	 *
	 * // Sets the value "baz" to the property bar
	 * await piece.set("foo", "baz", "bar");
	 * @returns {Promise<void>} A promise
	 */
	async set(key, val, path) {
		this[_closeCheck]();

		if (_.isNil(key)) {
			throw new Err("key is null or undefined");
		}
		if (_.isNil(val)) {
			throw new Err("val is null or undefined");
		}

		if (!this[_typeCheck](key)) {
			key = key.toString();
		}

		if (path) {
			let rawData;

			if (this.hasCache) {
				await this[_cacheDefer];

				if (this.cache.has(key)) {
					rawData = { value: this.cache.get(key) };
				} else {
					rawData = await this.base.findOne({ _id: key });
				}
			} else {
				rawData = await this.base.findOne({ _id: key });
			}

			val = _.set(rawData.value || {}, path, val);
		}

		await this.base.updateOne({ _id: key }, { $set: { _id: key, value: val } }, { upsert: true });
	}

	/**
	 * @async
	 * @description Push to an array value.
	 * @since 1.2
	 * @param {*} key - The key of the document.
	 * @param {*} val - The value to push.
	 * @param {string} [path=null] - Optional. The path to the property to modify inside the element. Can be a dot-separated path, such as "prop1.subprop2.subprop3".
	 * @param {boolean} [allowDupes=false] - Optional. Allow duplicate values in the array.
	 * @example
	 * // Adds the value "bar" to the array that is the value of the key "foo"
	 * await piece.push("foo", "bar");
	 *
	 * // We can also do that for properties that are arrays
	 * await piece.push("foo", "baz", "bar");
	 * @returns {Promise<void>} A promise
	 */
	async push(key, val, path, allowDupes = false) {
		this[_closeCheck]();

		if (_.isNil(key)) {
			throw new Err("key is null or undefined");
		}
		if (_.isNil(val)) {
			throw new Err("val is null or undefined");
		}
		if (!this[_typeCheck](key)) {
			key = key.toString();
		}

		let rawData;
		if (this.hasCache) {
			await this[_cacheDefer];

			if (this.cache.has(key)) {
				rawData = { value: this.cache.get(key) };
			} else {
				rawData = await this.base.findOne({ _id: key });
			}
		} else {
			rawData = await this.base.findOne({ _id: key });
		}

		if (_.isNil(rawData) || _.isNil(rawData.value)) {
			rawData = { value: [] };
		}

		let data;
		let finalData;
		if (path) {
			if (!_.isArray(_.get(rawData.value, path))) {
				throw new Err("The element you tried to modify is not an array");
			}
			data = _.isArray(_.get(rawData.value, path)) ? rawData.value : [];

			if (data.indexOf(val) > -1 && !allowDupes) {
				return;
			}

			data.push(val);

			finalData = _.set(rawData.value, path, data);
		} else {
			if (!_.isArray(rawData.value)) {
				throw new Err("The element you tried to modify is not an array");
			}
			data = _.isArray(rawData.value) ? rawData.value : [];

			if (data.indexOf(val) > -1 && !allowDupes) {
				return;
			}

			data.push(val);

			finalData = data;
		}

		await this.base.updateOne(
			{ _id: key },
			{ $set: { _id: key, value: finalData } },
			{ upsert: true },
		);
	}

	/**
	 * @async
	 * @description Gets a document in the database.
	 * @since 1.0
	 * @param {*} key - The key of the document to get.
	 * @param {string} [path=null] - Optional. The path to the property to take inside the value. Can be a dot-separated path, such as "prop1.subprop2.subprop3".
	 * @param {boolean} [raw=false] - Optional. Returns the full object, i.e. : { _id: "foo", value: "bar" }. Not used if you don't use the cache.
	 * @example
	 * // Returns the value for the key foo
	 * const data = await piece.get("foo");
	 *
	 * // Returns the value of the property bar
	 * const data = await piece.get("foo", "bar")
	 * @returns {Promise<any>} If raw is set to false, returns the value found in the database for this key.
	 */
	async get(key, path, raw = false) {
		this[_closeCheck]();

		if (_.isNil(key)) {
			return null;
		}

		if (!this[_typeCheck](key)) {
			key = key.toString();
		}

		let rawData;
		let data;
		try {
			if (this.hasCache) {
				await this[_cacheDefer];

				if (this.cache.has(key)) {
					rawData = { _id: key, value: this.cache.get(key) };
				} else {
					rawData = await this.base.findOne({ _id: key });
					this.cache.set(key, rawData.value);
				}
			} else {
				rawData = await this.base.findOne({ _id: key });
			}

			data = rawData.value;
		} catch (e) {
			return null;
		}

		if (_.isNil(data)) {
			return null;
		}

		if (raw && !this.hasCache) {
			return rawData;
		}

		if (path) {
			return _.get(data, path);
		} else {
			return data;
		}
	}

	/**
	 * @async
	 * @description Do not works if the cache is not activated. Fetch a document on the database.
	 * @since 1.2
	 * @param {*} key - The key of the document to get
	 * @param {string} [path=null] - Optional. The path to the property to take inside the value. Can be a dot-separated path, such as "prop1.subprop2.subprop3".
	 * @param {boolean} [raw=false] - Optional. Returns the full object, i.e. : { _id: "foo", value: "bar" }. Not used if you don't use the cache.
	 * @example
	 * // Returns the value for the key foo and update the cache
	 * const data = await piece.fetch("foo");
	 *
	 * // Returns the value of the property bar and update the cache
	 * const data = await piece.fetch("foo", "bar")
	 * @returns {Promise<any>} If raw is set to false, returns the value found in the database for this key.
	 */
	async fetch(key, path, raw = false) {
		this[_closeCheck]();

		if (!this.hasCache) {
			throw new Err("The cache is not activated, you can't use this method");
		}

		if (_.isNil(key)) {
			return null;
		}

		if (!this[_typeCheck](key)) {
			key = key.toString();
		}

		let rawData;
		let data;
		try {
			rawData = await this.base.findOne({ _id: key });

			data = rawData.value;
		} catch (e) {
			return null;
		}

		if (_.isNil(data)) {
			return null;
		}

		this.cache.set(key, data);

		if (raw) {
			return rawData;
		}

		if (path) {
			return _.get(data, path);
		} else {
			return data;
		}
	}

	/**
	 * @async
	 * @description Fetch all the database and caches it all. It also updates already cached data.
	 * @since 1.2
	 * @returns {Promise<void>} Nothing
	 */
	async fetchAll() {
		this[_closeCheck]();

		if (!this.hasCache) {
			throw new Err("The cache is not activated, you can't use this method");
		}

		await this.base.find({}).forEach((d) => {
			this.cache.set(d._id, d.value);
		});
	}

	/**
	 * @async
	 * @description Checks if a document exists.
	 * @since 1.0
	 * @param {*} key - The key of the document to check.
	 * @param {string} [path=null] - Optional. The path to the property to check. Can be a dot-separated path, such as "prop1.subprop2.subprop3".
	 * @example
	 * // If the key "foo" doesn't exists, it returns
	 * if (!await piece.has("foo")) return;
	 *
	 * // If the property "bar" of the value of "foo" dosn't exists, it returns
	 * if (!await piece.has("foo", "bar")) return;
	 * @returns {Promise<boolean>} A promise
	 */
	async has(key, path) {
		this[_closeCheck]();

		if (!this[_typeCheck](key)) {
			key = key.toString();
		}

		let rawData;
		if (this.hasCache) {
			await this[_cacheDefer];

			if (this.cache.has(key)) {
				rawData = { value: this.cache.get(key) };
			} else {
				rawData = await this.base.findOne({ _id: key });
			}
		} else {
			rawData = await this.base.findOne({ _id: key });
		}
		if (_.isNil(rawData)) {
			return false;
		}

		const data = rawData.value;
		if (_.isNil(data)) {
			return false;
		}

		if (this.hasCache) {
			if (!this.cache.has(key)) {
				this.cache.set(key, data);
			}
		}

		if (path) {
			return _.has(data, path);
		} else {
			return true;
		}
	}

	/**
	 * @async
	 * @description If the document doesn't exists : creates it and returns it, if it does exists : returns it.
	 * @since 1.0
	 * @param {*} key - The key to check if it exists or to set a document or a property inside the value.
	 * @param {*} val - The value to set if the key doesn't exist.
	 * @param {string} [path=null] - Optional. The path to the property to ensure. Can be a dot-separated path, such as "prop1.subprop2.subprop3".
	 * @param {boolean} [raw=false] - Optional. Returns the full object, i.e. : { _id: "foo", value: "bar" }. Not used if you don't use the cache.
	 * @example
	 * // If foo exists it returns its value, if it doesn't it set its value to "bar" and returns it
	 * const data = piece.ensure("foo", "bar");
	 * @returns {Promise<any>} If raw is set to false, returns the value found in the database for this key.
	 */
	async ensure(key, val, path, raw = false) {
		this[_closeCheck]();

		if (_.isNil(key)) {
			throw new Err("key is null or undefined");
		}
		if (_.isNil(val)) {
			throw new Err("val is null or undefined");
		}

		if (!(await this.has(key, path))) {
			await this.set(key, val, path);
		}

		return this.get(key, path, raw);
	}

	/**
	 * @async
	 * @description Deletes a document in the database.
	 * @since 1.0
	 * @param {*} key - The key.
	 * @param {string} [path=null] - Optional. The path to the property to delete. Can be a dot-separated path, such as "prop1.subprop2.subprop3".
	 * @returns {Promise<void>} A promise
	 */
	async delete(key, path) {
		this[_closeCheck]();

		if (_.isNil(key)) {
			throw new Err("key is null or undefined");
		}

		if (!this[_typeCheck](key)) {
			key = key.toString();
		}

		if (path) {
			let rawData;
			if (this.hasCache) {
				await this[_cacheDefer];

				if (this.cache.has(key)) {
					rawData = { value: this.cache.get(key) };
				} else {
					rawData = await this.base.findOne({ _id: key });
				}
			} else {
				rawData = await this.base.findOne({ _id: key });
			}

			let data = rawData.value;
			if (_.isNil(rawData) || _.isNil(data)) {
				return;
			}

			path = _.toPath(path);
			const last = path.pop();
			const propValue = path.length ? _.get(data, path) : data;

			if (_.isArray(propValue)) {
				propValue.splice(last, 1);
			} else {
				delete propValue[last];
			}

			if (path.length) {
				_.set(data, path, propValue);
			} else {
				data = propValue;
			}

			await this.base.updateOne(
				{ _id: key },
				{ $set: { _id: key, value: data } },
				{ upsert: true },
			);
		} else {
			await this.base.deleteOne({ _id: key }, { single: true });
		}
	}

	/**
	 * @async
	 * @description Deletes all the documents.
	 * @since 1.0
	 * @returns {Promise<void>} A promise
	 */
	async clear() {
		this[_closeCheck]();

		await this.base.deleteMany({});
	}

	/**
	 * @description Do not works if the cache is not activated. Removes a cached element, it does not touch the real database.
	 * @since 1.2
	 * @param {*} key - The key.
	 * @param {string} [path=null] - Optional. The path to the property to uncache. Can be a dot-separated path, such as "prop1.subprop2.subprop3".
	 * @returns {void} - Nothing
	 */
	evict(key, path) {
		if (!this.hasCache) {
			throw new Err("The cache is not activated, you can't use this method");
		}
		if (_.isNil(key)) {
			throw new Err("key is null or undefined");
		}

		this[_cacheDefer].then(() => {
			if (!this[_typeCheck](key)) {
				key = key.toString();
			}

			if (path) {
				let data;
				if (this.cache.has(key)) {
					data = this.cache.get(key);
				} else {
					return;
				}

				if (_.isNil(data)) {
					return;
				}

				path = _.toPath(path);
				const last = path.pop();
				const propValue = path.length ? _.get(data, path) : data;

				if (_.isArray(propValue)) {
					propValue.splice(last, 1);
				} else {
					delete propValue[last];
				}

				if (path.length) {
					_.set(data, path, propValue);
				} else {
					data = propValue;
				}

				this.cache.set(key, data);
			} else {
				this.cache.delete(key);
			}
		});
	}

	/**
	 * @description Do not works if the cache is not activated. Removes all the cached elements. It does not touch the real database.
	 * @since 1.2
	 * @returns {void} - Nothing
	 */
	evictAll() {
		if (!this.hasCache) {
			throw new Err("The cache is not activated, you can't use this method");
		}

		this[_cacheDefer].then(() => {
			this.cache.clear();
		});
	}

	/**
	 * @since 1.0
	 * @async
	 * @param {boolean} [cache=true] - Optional. If there is a [cache]{@link Piece~cache}, per defaut it is set to true and it will takes cached data. If you set it to false, it will takes the data from the Mongo server.
	 * @returns {Promise<Array<*>>} A promise. When resolved, returns an array with the values of all the documents
	 */
	async valueArray(cache = true) {
		if (this.hasCache && cache) {
			await this[_cacheDefer];

			const values = [];

			for (const value of this.cache.values()) {
				values.push(value);
			}

			return values;
		} else {
			this[_closeCheck]();

			return this.base
				.find({})
				.map((d) => d.value)
				.toArray();
		}
	}

	/**
	 * @since 1.0
	 * @async
	 * @param {boolean} [cache=true] - Optional. If there is a [cache]{@link Piece~cache}, per defaut it is set to true and it will takes cached data. If you set it to false, it will takes the data from the Mongo server.
	 * @returns {Promise<Array<*>>} A promise. When resolved, returns an array with the keys of all the documents
	 */
	async keyArray(cache = true) {
		if (this.hasCache && cache) {
			await this[_cacheDefer];

			const keys = [];

			for (const key of this.cache.keys()) {
				keys.push(key);
			}

			return keys;
		} else {
			this[_closeCheck]();

			return this.base
				.find({})
				.map((d) => d._id)
				.toArray();
		}
	}

	/**
	 * @since 1.0
	 * @async
	 * @returns {Promise<Array<any>>} An array with all the documents of the database
	 */
	rawArray() {
		this[_closeCheck]();

		return this.base.find({}).toArray();
	}

	/**
	 * @async
	 * @since 1.0
	 * @param {boolean} [fast=false] - Optional. Set to true if your database is very big (the size will be less precise but it will be faster).
	 * @returns {Promise<number>} A promise returning the size of the database when resolved
	 */
	size(fast = false) {
		this[_closeCheck]();

		if (fast) {
			return this.base.estimatedDocumentCount();
		} else {
			return this.base.countDocuments();
		}
	}
}

module.exports = Piece;