potatodb
Version:
A nodejs filesystem json database system
986 lines (874 loc) • 38.4 kB
JavaScript
// requires
const fs = require("fs");
const { PotatoArray, PotatoId, PotatoError } = require("./potatoes.js");
const {
extractNestedProperty,
deepCopy,
deepEqual,
selectFields,
} = require("./utils.js");
/**
* A farm is a collection of data potatoes (documents).
*/
class Farm {
/**
* Create a farm (collection of data).
*
* @param {string} farmName The name of the farm.
* @param {string} farmPath The path to the farm file.
* @param {string} dbName The name of the database that contains this farm.
* @param {boolean} _id Specifies whether potatoes (documents) in this farm should be stamped with id strings or not.
* @param {boolean} timestamps Specifies whether potatoes (documents) in this farm
* should be stamped with timestamps or not.
*/
constructor(farmName, farmPath, dbName, _id, timestamps) {
this.farmName = farmName;
this.farmPath = farmPath;
this.dbName = dbName;
this._id = _id;
this.timestamps = timestamps;
}
// PRIVATE METHODS
/**
* Updates a nested property inside an object using a string path that directs to the property.
*
* @private
* @static
* @param {Object} object The object that contains the nested property.
* @param {string} path The string path to the nested property.
* @param {any} val The new value of the nested property.
*/
static #nestedUpdate(object, path, val) {
const props = path.split(".");
props.reduce((obj, prop, index) => {
return (obj[prop] = props.length === ++index ? val : obj[prop]);
}, object);
}
/**
* Takes a query object or a test function, if a query object was passed,
* it will be converted to a test function.
*
* @private
* @static
* @param {Object | Function} test query object or test function.
* @returns {Function} A test function.
*/
static #transform(test) {
if (typeof test !== "object") return test;
return (potato) => {
let matching = [];
if (Object.keys(test).length === 0) {
return true;
}
for (let key in test) {
const extractedData = extractNestedProperty(potato, key);
if (key.startsWith("$")) {
const results = [];
for (let subquery of test[key]) {
results.push(Farm.#transform(subquery));
}
/**
* Logical Operators
* Handling multiple query objects with the $and/$or logical operators
*
* Operators:
* $and => logical AND : all provided queries must pass
* $or => logical OR : at least one of queries must pass
* $nor => logical NOR : all provided queries must fail
*/
switch (key) {
case "$and": {
matching.push(
results.every((result) => result(potato))
);
break;
}
case "$or": {
matching.push(
results.some((result) => result(potato))
);
break;
}
case "$nor": {
matching.push(
results.every((result) => !result(potato))
);
break;
}
}
} else if (test[key] instanceof RegExp) {
matching.push(test[key].test(extractedData));
} else if (typeof test[key] === "object") {
for (let specialKey in test[key]) {
const passedData = test[key][specialKey];
const passedIsArray =
Array.isArray(passedData) &&
!Array.isArray(extractedData);
const extractedIsArray =
Array.isArray(extractedData) &&
!Array.isArray(passedData);
const bothInputsAreArrays =
Array.isArray(extractedData) &&
Array.isArray(passedData);
/**
* Query Operators
* Query operators are used to make querying farms and selecting data easier.
*
* Operators:
* $gt => greater than
* $gte => greater than or equal to
* $lt => less than
* $lte => less than or equal to
* $eq => equal to
* $eqv => equal to (regardless of value data type)
* $neq => not equal to
* $neqv => not equal to (regardless of value data type)
* $in => checks if array/string includes a value (array/string to string)
* => checks if value is among list of values (string to array)
* => checks if array contains some of values (array to array)
* $nin => checks if array/string does not include a value (array/string to string)
* => checks if value is not among list of values (string to array)
* => checks if array does not contain any of values (array to array)
* $all => checks if array contains all values (array to array)
* $elemMatch => checks if array contains a specific subdocument
*/
switch (specialKey) {
case "$gt": {
matching.push(extractedData > passedData);
break;
}
case "$gte": {
matching.push(extractedData >= passedData);
break;
}
case "$lt": {
matching.push(extractedData < passedData);
break;
}
case "$lte": {
matching.push(extractedData <= passedData);
break;
}
case "$eq": {
matching.push(extractedData === passedData);
break;
}
case "$eqv": {
matching.push(extractedData == passedData);
break;
}
case "$neq": {
matching.push(extractedData !== passedData);
break;
}
case "$neqv": {
matching.push(extractedData != passedData);
break;
}
case "$in": {
matching.push(
(extractedIsArray &&
extractedData.includes(passedData)) ||
(passedIsArray &&
passedData.includes(
extractedData
)) ||
(bothInputsAreArrays &&
passedData.some((passedItem) =>
extractedData.includes(
passedItem
)
))
);
break;
}
case "$nin": {
matching.push(
(extractedIsArray &&
!extractedData.includes(passedData)) ||
(passedIsArray &&
!passedData.includes(
extractedData
)) ||
(bothInputsAreArrays &&
!passedData.some((passedItem) =>
extractedData.includes(
passedItem
)
))
);
break;
}
case "$all": {
matching.push(
bothInputsAreArrays &&
passedData.every((passedItem) =>
extractedData.includes(passedItem)
)
);
break;
}
case "$elemMatch": {
matching.push(
extractedData.some((data) =>
deepEqual(data, passedData)
)
);
break;
}
}
}
} else {
matching.push(test[key] === extractedData);
}
}
return matching.every((condition) => condition === true);
};
}
/**
* Validates the passed test, throws an error if typeof test is not
* an object or a function. If test was nullish, it will be replaced with an empty object.
*
* @private
* @static
* @param {string} caller The name of the function that called this method.
* @param {Object | Function} test A query object or a test function.
* @returns {Object | Function} The query object or the test function.
*/
static #validateQuery(caller, test) {
if (!test) {
test = {};
}
if (typeof test !== "function" && typeof test !== "object") {
throw new PotatoError(
`${caller} expected a test function or a query object as a first argument`
);
}
return test;
}
/**
* Gets the stored data from the farm file.
*
* @async
* @private
* @returns {Promise<Object[]>} An array of potatoes (documents).
*/
async #getData() {
let data = await fs.promises.readFile(this.farmPath, "utf8");
data = JSON.parse(data);
return new PotatoArray(...data);
}
/**
* @typedef {Object} PreFindInterceptorOptions
* @property {boolean} [recent=false] Defines whether priority of search should be to older or recent potato documents.
* @property {number} [skip=0] Defines the number of potato documents to be skipped before beginning the search.
*/
/**
* Pre interceptor applies some options to the data before query is done.
*
* @private
* @static
* @param {Object[]} data The data.
* @param {PreFindInterceptorOptions} [options] Options object.
* @returns {Object[]} Data after applying the options.
*/
static #preInterceptor(data, options) {
const { recent = false, skip = 0 } = options || {};
// reversing
if (recent) {
data = data.reverse();
}
// skipping
if (skip < 0)
throw new PotatoError("Skip value must be a positive number");
data = data.slice(skip);
return data;
}
/**
* @typedef {Object} PostInterceptorOptions
* @property {number} [limit] Defines the maximum number of returned potato documents.
* @property {Object | Function} [sort] Sorting object or function.
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
*/
/**
* Post interceptor applied some options to the result after query is done.
*
* @private
* @static
* @async
* @param {string} caller The name of the function that called this method.
* @param {Object | Object[]} result The result object or an array of result objects.
* @param {PostInterceptorOptions} [options] Options object.
* @returns {Promise<Object | Object[]>} The final result object or an array of final result objects.
*/
static async #postInterceptor(caller, result, options) {
let { select, populate, sort, limit } = options || {};
const isArray = Array.isArray(result);
if (isArray) {
// limiting
if (limit && caller.startsWith("find")) {
result =
limit < 0 ? result.slice(limit) : result.slice(0, limit);
}
// sorting
if (sort) {
result = result.sort(sort);
}
}
// populating
if (
populate &&
typeof populate === "object" &&
typeof !Array.isArray(populate)
) {
result = isArray ? result : [result];
for (const ref in populate) {
for (const doc of result) {
if (doc[ref]) {
doc[ref] = await populate[ref].findOne({
_id: doc[ref],
});
}
}
}
result = isArray ? result : result[0];
}
// selection
if (isArray) {
result = result.map((obj) => selectFields(obj, select));
} else {
result = selectFields(result, select);
}
return isArray ? new PotatoArray(...result) : result;
}
/**
* @typedef {Object} InsertOneOptions
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
*/
/**
* @typedef {Object} InsertManyOptions
* @property {Object} [sort] Sorting object or function.
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
*/
/**
* Inserts potatoes into the farm file.
*
* @async
* @private
* @param {"insertOne" | "insertMany"} caller The name of the function that called this method.
* @param {Object | Object[]} newData A potato document, or an array of them.
* @param {InsertOneOptions | InsertManyOptions} [options] Options object.
* @returns {Promise<Object | Object[]>} The inserted potato document or the array of inserted potato documents.
*/
async #insertLogic(caller, newData, options) {
try {
// validation
if (typeof newData !== "object") {
throw new PotatoError(`${caller} expected a potato document`);
} else if (caller === "insertOne" && Array.isArray(newData)) {
throw new PotatoError("insertOne accepts a single potato only");
} else if (caller === "insertMany" && !Array.isArray(newData)) {
throw new PotatoError(
"insertMany accepts an array of potatoes only"
);
}
const isArray = Array.isArray(newData);
const data = await this.#getData();
const { _id, timestamps } = this;
/**
* Attaches id string and timestamps on the potato document.
* @param {Object} obj Potato document object.
*/
function addStamps(obj) {
if (_id) {
Object.defineProperty(obj, "_id", {
value: new PotatoId()._id,
enumerable: true,
});
}
if (timestamps) {
const date = Date.now();
Object.defineProperties(obj, {
createdAt: {
value: date,
enumerable: true,
},
updatedAt: {
value: date,
enumerable: true,
writable: true,
},
});
}
}
if (!isArray) {
addStamps(newData);
data.push(newData);
} else {
newData.forEach((obj) => addStamps(obj));
data.push(...newData);
}
await fs.promises.writeFile(this.farmPath, JSON.stringify(data));
return Farm.#postInterceptor(caller, newData, options);
} catch (err) {
throw new PotatoError(err.message);
}
}
/**
* Inserts one potato document into the farm.
*
* @async
* @param {Object} newData Potato (document) object to insert in the farm (collection).
* @param {InsertOneOptions} [options] Options object.
* @returns {Promise<Object>} The potato document inserted.
*/
async insertOne(newData, options) {
return await this.#insertLogic("insertOne", newData, options);
}
/**
* Inserts multiple potato documents into the farm.
*
* @async
* @param {Object[]} newData Potato documents to insert in the farm (collection).
* @param {InsertManyOptions} [options] Options object.
* @returns {Promise<Object[]>} An array of the inserted potato documents.
*/
async insertMany(newData, options) {
return await this.#insertLogic("insertMany", newData, options);
}
/**
* @typedef {Object} FindOneOptions
* @property {boolean} [recent=false] Defines whether priority of search should be to older or recent potato documents.
* @property {number} [skip=0] Defines the number of potato documents to be skipped before beginning the search.
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
*/
/**
* @typedef {Object} FindManyOptions
* @property {boolean} [recent=false] Defines whether priority of search should be to older or recent potato documents.
* @property {number} [skip=0] Defines the number of potato documents to be skipped before beginning the search.
* @property {number} [limit] Defines the maximum number of returned potato documents.
* @property {Object | Function} [sort] Sorting object or function.
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
*/
/**
* Gets potatoes from the farm file.
*
* @async
* @private
* @param {"findOne" | "findMany"} caller The name of the function that called this method.
* @param {Object | Function} [test] A query object or a test function.
* @param {FindOneOptions | FindManyOptions} [options] Options object.
* @returns {Promise<Object | Object[] | null>} A single potato document or an array of them.
*/
async #findLogic(caller, test, options) {
try {
let data = await this.#getData();
// pre interceptor
data = Farm.#preInterceptor(data, options);
// transformation
test = Farm.#validateQuery(caller, test);
test = Farm.#transform(test);
// filtering
let result;
if (caller === "findOne") {
result = data.find(test);
if (!result) {
return null;
}
} else if (caller === "findMany") {
result = data.filter(test);
} else throw new PotatoError("Invalid caller");
// post interceptor
return await Farm.#postInterceptor(caller, result, options);
} catch (err) {
throw new PotatoError(err.message);
}
}
/**
* Gets a single potato document from the farm.
*
* @async
* @param {Object | Function} [test] A query object or a test function.
* @param {FindOneOptions} [options] The options object.
* @returns {Promise<Object | null>} A single potato document.
*/
async findOne(test, options) {
return await this.#findLogic("findOne", test, options);
}
/**
* Gets multiple potato documents from the farm.
*
* @async
* @param {Object | Function} [test] A query object or a test function.
* @param {FindManyOptions} [options] The options object.
* @returns {Promise<Object[]>} An array of potato documents.
*/
async findMany(test, options) {
return await this.#findLogic("findMany", test, options);
}
/**
* @typedef {Object} UpdateOneOptions
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
* @property {boolean} [updated=true] Specifies whether the returned potato document should be the pre-updated or the post-updated version.
*/
/**
* @typedef {Object} UpdateManyOptions
* @property {Object | Function} [sort] Sorting object or function.
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
* @property {boolean} [updated=true] Specifies whether the returned potato document should be the pre-updated or the post-updated version.
*/
/**
* Updates specified potatoes in the farm file.
*
* @async
* @private
* @param {"updateOne" | "updateMany"} caller The name of the function that called this method.
* @param {Object | Function} test A query object or a test function.
* @param {Object | Function} updates An updates object or an update function.
* @param {UpdateOneOptions | UpdateManyOptions} [options] Options object.
* @returns {Promise<Object | Object[] | null>} The updated potato document or an array of updated potato documents.
*/
async #updateLogic(caller, test, updates, options) {
try {
// options
const updated = options?.updated ?? true;
// transformation
test = Farm.#validateQuery(caller, test);
test = Farm.#transform(test);
if (
!updates &&
typeof updates !== "function" &&
typeof updates !== "object"
) {
throw new PotatoError(
`${caller} expected an updates object or function as a second argument`
);
}
// update process
const data = await this.#getData();
let results = [];
/**
* Updates potato document properties in unique ways.
* @param {string} update Update operator.
* @param {number} index Potato document index.
*/
function operatorAction(update, index) {
const keys = Object.keys(updates[update]);
/**
* Update operators:
* $inc => increments a number value
* $push => pushes a value into an array
* $addToSet => adds a value into a set only if value doesn't already exist
* $pull => removes a specific value from an array
* $concat => concatenates two values of same type (string or array) together
*/
for (let key of keys) {
let target = extractNestedProperty(data[index], key);
let change = updates[update][key];
switch (update) {
case "$inc": {
target += change;
break;
}
case "$push": {
target.push(change);
break;
}
case "$addToSet": {
const targetSet = new Set(target);
targetSet.add(change);
target = [...targetSet];
break;
}
case "$pull": {
target = target.filter((item) => item !== change);
break;
}
case "$pop": {
if (change === 1) {
target.pop();
} else if (change === -1) {
target.shift();
}
break;
}
case "$concat": {
target = target.concat(change);
break;
}
}
Farm.#nestedUpdate(data[index], key, target);
}
}
const operatorUpdates = {};
const nonOperatorUpdates = {};
if (typeof updates === "function") {
// alter update function so that it by default return the updated potato document
const originalUpdatesFunc = updates;
updates = function (item) {
originalUpdatesFunc(item);
return item;
};
} else {
// sort updates types
for (let update in updates) {
if (update.startsWith("$")) {
operatorUpdates[update] = updates[update];
} else {
nonOperatorUpdates[update] = updates[update];
}
}
}
if (caller === "updateOne") {
const index = data.findIndex(test);
if (index === -1) {
return null;
}
if (!updated) results = deepCopy(data[index]);
if (typeof updates === "function") {
data[index] = updates(data[index]);
} else {
for (let update in operatorUpdates) {
operatorAction(update, index);
}
for (let update in nonOperatorUpdates) {
Farm.#nestedUpdate(
data[index],
update,
nonOperatorUpdates[update]
);
}
// update the timestamp
if (this.timestamps) {
data[index].updatedAt = Date.now();
}
}
if (updated) results = deepCopy(data[index]);
} else if (caller === "updateMany") {
const indexes = data
.map((potato, index) => {
return test(potato) ? index : -1;
})
.filter((index) => index !== -1);
for (let i = 0; i < indexes.length; i++) {
if (!updated) results.push(deepCopy(data[indexes[i]]));
if (typeof updates === "function") {
data[indexes[i]] = updates(data[indexes[i]]);
} else {
for (let update in operatorUpdates) {
operatorAction(update, indexes[i]);
}
for (let update in nonOperatorUpdates) {
Farm.#nestedUpdate(
data[indexes[i]],
update,
nonOperatorUpdates[update]
);
}
// update the timestamp
if (this.timestamps) {
data[indexes[i]].updatedAt = Date.now();
}
}
if (updated) results.push(deepCopy(data[indexes[i]]));
}
} else throw new PotatoError("Invalid caller");
await fs.promises.writeFile(this.farmPath, JSON.stringify(data));
return await Farm.#postInterceptor(caller, results, options);
} catch (err) {
throw new PotatoError(err.message);
}
}
/**
* Updates a single potato document in the farm.
*
* @async
* @param {Object | Function} test A query object or a test function.
* @param {Object | Function} updates An update object or update function.
* @param {UpdateOneOptions} [options] Options object.
* @returns {Promise<Object | null>} The updated potato document.
*/
async updateOne(test, updates, options) {
return await this.#updateLogic("updateOne", test, updates, options);
}
/**
* Updates multiple potato documents in the farm.
*
* @async
* @param {Object | Function} test A query object or a test function.
* @param {Object | Function} updates An update object or update function.
* @param {UpdateManyOptions} [options] Options object.
* @returns {Promise<Object[]>} An array of the updated potato documents.
*/
async updateMany(test, updates, options) {
return await this.#updateLogic("updateMany", test, updates, options);
}
/**
* @typedef {Object} DeleteOneOptions
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
*/
/**
* @typedef {Object} DeleteManyOptions
* @property {Object | Function} [sort] Sorting object or function.
* @property {Object} [select] Selection object to specify fields to include or exclude.
* @property {Object} [populate] Population object to fill references fields.
*/
/**
* Deletes specified potatoes from the farm file.
*
* @async
* @private
* @param {"deleteOne" | "deleteMany"} caller The name of the function that called this method.
* @param {Object | Function} [test] A query object or a test function.
* @param {DeleteOneOptions | DeleteManyOptions} [options] Options object.
* @returns {Promise<Object | Object[] | null>} The deleted potato document or an array of deleted potato documents.
*/
async #deleteLogic(caller, test, options) {
// validation
test = Farm.#validateQuery(caller, test);
test = Farm.#transform(test);
// delete process
try {
const data = await this.#getData();
let results = [];
if (caller == "deleteOne") {
const index = data.findIndex(test);
if (index === -1) {
return null;
}
results = data[index];
data.splice(index, 1);
} else if (caller == "deleteMany") {
let decrement = 0;
let indexes = data
.map((potato, index) => {
if (test(potato)) {
return index - decrement++;
}
return null;
})
.filter((index) => index !== null);
for (let i = 0; i < indexes.length; i++) {
results.push(data[indexes[i]]);
data.splice(indexes[i], 1);
}
} else throw new PotatoError("Invalid caller");
await fs.promises.writeFile(this.farmPath, JSON.stringify(data));
return await Farm.#postInterceptor(caller, results, options);
} catch (err) {
throw new PotatoError(err.message);
}
}
/**
* Deletes a single potato document from the farm.
*
* @async
* @param {Object | Function} [test] A query object or a test function.
* @param {DeleteOneOptions} [options] Options object.
* @returns {Promise<Object | null>} The deleted potato document.
*/
async deleteOne(test, options) {
return await this.#deleteLogic("deleteOne", test, options);
}
/**
* Deletes multiple potato documents from the farm.
*
* @async
* @param {Object | Function} [test] A query object or a test function.
* @param {DeleteManyOptions} [options] Options object.
* @returns {Promise<Object[]>} An array of the deleted potato documents.
*/
async deleteMany(test, options) {
return await this.#deleteLogic("deleteMany", test, options);
}
/**
* Checks if a potato document exists in the farm file.
*
* @async
* @param {Object | Function} test A query object or a test function.
* @returns {Promise<boolean>} A boolean value indicating whether the potato document exists or not.
*/
async exists(test) {
return !!(await this.findOne(test));
}
/**
* Counts the number of potato documents stored in the farm (collection) file.
*
* @async
* @returns {Promise<number>} The number of stored potato documents in farm.
*/
async countPotatoes(test) {
try {
const data = await this.findMany(test);
// const data = await this.#getData();
return data.length;
} catch (err) {
throw new PotatoError(err.message);
}
}
/**
* Gets a random a single potato document from the farm file.
*
* @async
* @returns {Promise<Object | null>} A single potato document.
*/
async sampleOne() {
const data = await this.#getData();
return data[Math.floor(Math.random() * data.length)] || null;
}
/**
* Gets multiple random potato documents from the farm file.
*
* @async
* @param {number} count The number of potato documents to sample.
* @returns {Promise<Object[]>} An array of potato documents.
*/
async sampleMany(count) {
const data = await this.#getData();
const samples = [];
for (let i = 0; i < count; i++) {
const sample = data[Math.floor(Math.random() * data.length)];
if (sample !== undefined) {
samples.push(sample);
}
}
return new PotatoArray(...samples);
}
/**
* Gets multiple random unique potato documents from the farm file.
*
* @async
* @param {number} count The number of unique potato documents to sample.
* @returns {Promise<Object[]>} An array of unique potato documents.
*/
async sampleManyUnique(count) {
const data = await this.#getData();
// fisher-yates shuffle
for (let i = data.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[data[i], data[j]] = [data[j], data[i]];
}
if (count >= data.length) return data;
return new PotatoArray(...data.slice(0, count));
}
/**
* Deletes this farm.
*/
dropFarm() {
try {
fs.unlinkSync(this.farmPath);
} catch (err) {
throw new PotatoError(err.message);
}
}
}
// exports
module.exports = Farm;