/*
Doggo DB 🐶
*/
let databaseScope = require("./databaseScope")();
/**
File ops coming soon.
*/
let unserialize = JSON.parse;
let serialize = JSON.stringify;
/**
Predefined List of Errors to manipulate them all at one place.
*/
let errors = {
NODBNAME: "No Databse Name provided.",
NODBEXISTS: "No Databse with that name exists.",
DBALREADYEXISTS: "Database with that name already exists.",
NOTABLENAME: "No Table Name provided.",
TABLENOTFOUND: "Table with that name not found.",
NODATAPROVIDED: "No Data provided.",
NOACTIVETABLE: "No reference to table created.",
TABLEALREADYEXISTS: "Table with that name already exists.",
NOTAVALIDOBJECT: "Not a valid object for storage.",
};
/**
The following are reserved fields and filters that serve as 'keywords' and must not be used inside a row of a table in the database.
The reservedFilters are filters that the user can use for querying, refer to the querying docs for a guide on that.
*/
let reservedFieldNames = { entryId: true };
let reservedFilters = {
$not: "not",
$in: "in",
$notIn: "not-in",
$includes: "includes",
$notIncludes: "not-includes",
$any: "any",
};
let reservedConditionals = {
$or: "or",
};
/**
* Gets the database with dbName.
* @param {string} dbName The name of the database.
* @return {string} The serialized/stringified database data in localStorage.
*/
function getDatabase(dbName) {
return databaseScope.getItem(dbName);
}
/**
* Creates a new database with dbName.
* Doesn't allow overwriting the previously existing one.
* @param {string} dbName - The name of the database.
* @return {undefined}
*/
function createDatabase(dbName) {
if (databaseScope.getItem(dbName)) throw new Error(errors.DBALREADYEXISTS);
return databaseScope.setItem(
dbName,
serialize({
tables: {},
createdAt: new Date(),
updatedAt: new Date(),
})
);
}
/**
* Serializes an object and writes it to a database in localStorage.
* @param {string} dbName - The name of the database to write to.
* @param {object} databaseObject - The object to serialize and write.
* @return {undefined}
*/
function writeDatabase(dbName, databaseObject) {
return databaseScope.setItem(dbName, serialize(databaseObject));
}
/**
* The main database driver.
* @constructor
* @param { string } dbName - The name of the database to initialize. Connects to an existing database if localStorage already has it.
* @param { object } [listeners] - Object that contains functions that fire on certain events such as 'onChange'.
* @return { db } databaseInstance - The object that contains all data about the database that has been initialized.
*/
class db {
constructor(dbName, listeners = { onChange: null }) {
if (!dbName) throw new Error(errors.NODBNAME);
this.databaseName = dbName;
this.restoreToPreviousState();
// Event Listeners
this.onChange = null;
if (listeners && typeof listeners === "object") {
this.onChange = listeners.onChange || null;
// Add more listeners as required.
}
}
/**
Restores the database to previous/persisted state.
Can be used on start or during aborting a transaction.
*/
restoreToPreviousState() {
// Reverting database to previous state.
let previousDatabase = getDatabase(this.databaseName);
let unserializedDatabase = {
tables: {},
createdAt: new Date(),
updatedAt: new Date(),
};
if (previousDatabase) unserializedDatabase = unserialize(previousDatabase);
if (!unserializedDatabase) createDatabase(this.databaseName); // Database doesn't exist.
this.database = unserializedDatabase;
this.activeTable = null;
this.isTransacting = false; // To track whether there is a transaction going on the database.
}
/**
Saves the current database instance to localstorage.
@return { undefined }
*/
save() {
if (this.isTransacting) return; // Need to stop transaction before saving. Use commitTransaction for the same.
writeDatabase(this.databaseName, this.database);
this.database.updatedAt = new Date();
if (this.onChange && typeof this.onChange === "function")
this.onChange(this.database);
}
// Operations.
/**
Sets a table as the current active table for the database instance, before that it creates a new one if it doesn't already exist.
@param { String } tableName - The name of the table.
@return { db } DoggoDB Database Instance
*/
table(tableName) {
if (!tableName) throw new Error(errors.NOTABLENAME);
if (this.database.tables[tableName])
this.activeTable = this.database.tables[tableName];
else this.create(tableName);
return this;
}
/**
List all tables in the database as an object along with their contents and metadata.
@return { Object }
*/
list() {
return this.database.tables;
}
/**
Function to create a new table. This is equivalent to db.table(tableName) because the table function also creates a new table if it does not exist.
@param { String } tableName - The name of the new table to create.
@return { db } DoggoDB Database Instance
*/
create(tableName) {
if (!tableName) throw new Error(errors.NOTABLENAME);
if (tableName in this.database.tables)
throw new Error(errors.TABLEALREADYEXISTS);
this.activeTable = new Table(tableName);
this.database.tables[tableName] = this.activeTable;
this.save();
return this;
}
/**
Drops a table in the database.
@param { String } tableName - Name of the table to delete.
@return { db } DoggoDB Database Instance
*/
drop(tableName) {
// Code to drop an entire table.
// If no tableName is passed, it drops the active table if any.
if (tableName) {
if (this.activeTable && tableName === this.activeTable.tableName)
this.activeTable = null;
if (tableName in this.database.tables)
delete this.database.tables[tableName];
} else {
let tableNameToDelete = null;
if (this.activeTable) {
tableNameToDelete = this.activeTable.tableName;
this.activeTable = null;
}
if (tableNameToDelete in this.database.tables)
delete this.database.tables[tableNameToDelete];
}
this.save();
return this;
}
/**
Gets all contents of a table as an array of Objects.
@return { Array }
*/
get() {
if (this.activeTable) return this.activeTable.contents; // Get contents of the entire table.
throw new Error(errors.NOACTIVETABLE);
}
/**
Adds a new entry/row to the current active table in this database instance.
@param { Object } newRow - The object to insert as an entry. A new 'entryId' field is automatically added to it.
@return { db } DoggoDB Database Instance
*/
add(newRow) {
if (!newRow) throw new Error(errors.NODATAPROVIDED);
if (!this.activeTable) throw new Error(errors.NOACTIVETABLE);
if (typeof newRow !== "object" || !Object.keys(newRow).length)
throw new Error(errors.NOTAVALIDOBJECT);
this.activeTable.contents.push({
...newRow,
entryId: generateUniqueId(),
});
this.activeTable.updatedAt = new Date();
this.database.tables[this.activeTable.tableName] = this.activeTable;
this.save();
return this;
}
/**
Returns a result set from the active table.
@param { Object } filters - Key value pair to query a table by.
@param { Object } [limiters] - { offset: Number, limit: Number }
*/
find(
filters,
limiters = {
offset: 0,
limit: null,
}
) {
try {
let { limit, offset } = limiters;
if (!limit) limit = Infinity;
if (!offset) offset = 0;
if (
this.activeTable &&
this.activeTable.contents &&
this.activeTable.contents.length &&
filters &&
Object.keys(filters).length
) {
// Code to find a result set based on filters.
let resultSet = [],
index = 0;
for (let row of this.activeTable.contents) {
if (index >= offset && resultSet.length <= limit) {
let allFiltersMatch = rowMatchesFilters(row, filters);
if (allFiltersMatch) resultSet.push(row);
}
index++;
}
return resultSet;
} else if (this.activeTable && this.activeTable.contents) {
if (offset || limit !== Infinity) {
return [...this.activeTable.contents].slice(offset, offset + limit);
}
return this.activeTable.contents;
}
return [];
} catch (err) {
throw new Error(err.message);
}
}
/**
Updates certain set of rows from currently active table based on filters.
@param { Object } filters - The Map of filters that you would use in the "find" function.
@param { Object } updates - The Map of updates.
@param { Boolean } updateOnlyOne - If true it stops deleting at the first match.
@return { Boolean }
*/
findAndUpdate(filters, updates, updateOnlyOne = true) {
// Function to update a resultset in the database.
if (
this.activeTable &&
this.activeTable.contents &&
this.activeTable.contents.length &&
updates
) {
// Check if the user is not updating any reserved fields.
if (updates && Object.keys(updates).length > 0) {
for (let fieldToUpdate in updates) {
if (fieldToUpdate in reservedFieldNames)
delete updates[fieldToUpdate];
}
}
let matchFound = false;
for (
let rowIndex = 0;
rowIndex < this.activeTable.contents.length;
rowIndex++
) {
let allFiltersMatch = true;
let row = this.activeTable.contents[rowIndex];
if (filters && Object.keys(filters).length > 0)
allFiltersMatch = rowMatchesFilters(row, filters);
if (allFiltersMatch) {
matchFound = true;
row = { ...row, ...updates };
this.activeTable.contents[rowIndex] = row;
}
if (updateOnlyOne) break;
}
this.database.tables[this.activeTable.tableName] = this.activeTable;
this.save();
if (matchFound) return true;
}
return false;
}
/**
Updates a row at a given position.
@param { Number } rowIndex - The index (Starting from 0) of the row to update.
@param { Object } updates - The Map of updates.
@return { Boolean }
*/
updateAt(rowIndex, updates) {
// Function to update a row in a table at a certain index.
if (!this.activeTable) throw new Error(errors.NOACTIVETABLE);
if (
this.activeTable &&
this.activeTable.contents &&
this.activeTable.contents.length &&
rowIndex >= 0 &&
this.activeTable.contents.length > rowIndex &&
updates
) {
// Check if the user is not updating any reserved fields.
if (updates && Object.keys(updates).length > 0) {
for (let fieldToUpdate in updates)
if (fieldToUpdate in reservedFieldNames)
delete updates[fieldToUpdate];
}
this.activeTable.contents[rowIndex] = {
...this.activeTable.contents[rowIndex],
...updates,
};
this.activeTable.updatedAt = new Date();
this.database.tables[this.activeTable.tableName] = this.activeTable;
this.save();
return true;
}
return false;
}
/**
Deletes certain set of rows from currently active table based on filters.
@param { Object } filters - The Map of filters that you would use in the "find" function.
@param { Boolean } deleteOnlyOne - If true it stops deleting at the first match.
@return { Boolean }
*/
delete(filters, deleteOnlyOne = true) {
// Function to delete a row.
if (
this.activeTable &&
this.activeTable.contents &&
this.activeTable.contents.length &&
filters
) {
let matchFound = false;
for (
let rowIndex = 0;
rowIndex < this.activeTable.contents.length;
rowIndex++
) {
let row = this.activeTable.contents[rowIndex];
let allFiltersMatch = rowMatchesFilters(row, filters);
if (allFiltersMatch) {
matchFound = true;
this.activeTable.contents.splice(rowIndex, 1);
}
if (deleteOnlyOne) break;
}
this.database.tables[this.activeTable.tableName] = this.activeTable;
this.save();
if (matchFound) return true;
}
return false;
}
/* Transaction Handlers */
/**
Starts a transaction.
Any changes made after starting a transaction will only be committed to storage in case the commitTransaction function is called.
*/
startTransaction() {
this.isTransacting = true;
}
/**
Commits a transaction.
Any changes made after starting a transaction will be saved to storage.
*/
commitTransaction() {
this.isTransacting = false;
this.save();
}
/**
Aborts a transaction.
Any changes made after starting a transaction will be discarded and the database is started afresh from the last saved state from persisted storage.
*/
abortTransaction() {
this.isTransacting = false;
this.restoreToPreviousState();
}
}
// Abstraction instances.
/**
* The table class.
* @constructor
* @param { string } tableName - The name of the table to create.
* @return { Table } tableInstance - Contains the contents, tableName, createdAt { Date} and updatedAt { Date }.
*/
class Table {
constructor(tableName) {
if (!tableName) throw new Error("No table name provided.");
this.contents = [];
this.tableName = tableName;
this.createdAt = new Date();
this.updatedAt = new Date();
}
}
/* Other helper functions. */
/**
* Checks whether a table row matches filters or not.
* @param { Object } row - The row to validate.
* @param { Object } filters - The filters to validate against.
* @returns { Boolean }
*/
function rowMatchesFilters(row, filters) {
let allFiltersMatch = true;
for (let filter in filters) {
if (filter in reservedConditionals && typeof filters[filter] === "object") {
if (!verifyOrOperation(row, filters[filter])) {
allFiltersMatch = false;
break;
}
} else if (
filter in reservedFilters &&
typeof filters[filter] === "object"
) {
if (
!verifyByCustomOperation(
row,
Object.keys(filters[filter])[0],
reservedFilters[filter],
Object.values(filters[filter])[0]
)
) {
allFiltersMatch = false;
break;
}
} else {
if (filter.includes(".")) {
// Nested Field Querying.
let valueToCheckAgainst = getNestedField(row, filter);
if (!validateValueAgainstFilter(filters[filter], valueToCheckAgainst)) {
allFiltersMatch = false;
break;
}
} else if (
!(filter in row) ||
!validateValueAgainstFilter(filters[filter], row[filter])
) {
allFiltersMatch = false;
break;
}
}
}
return allFiltersMatch;
}
/**
* Generates a unique id to be used in fields such as 'entryId'.
* @return { Number } A sufficiently random number that can be used as a unique Id.
*/
function generateUniqueId() {
return parseInt(
new Date().getTime() + Math.random() * 10 + Math.random() * 10
);
}
/**
* Function to check if a row in a table matches a specified operation or not. (Operations such as in, not, not-in, includes, not includes etc).
* @param { Object } row - The object representing a row in a database table.
* @param { field } field - Name of field to verify operation on.
* @param { * } valueToValidateOn - The value to compare with, type depends on the operation.
* @return { Boolean }
*/
function verifyByCustomOperation(row, field, operation, valueToValidateOn) {
if (!row || typeof row !== "object") throw new Error("Invalid row type.");
if (!row[field]) return false; // If the row does not have that column, simply don't use it.
let fieldValue = row[field];
if (field.includes(".")) fieldValue = getNestedField(row[field]);
switch (operation) {
case "in":
case "any":
if (!valueToValidateOn || !Array.isArray(valueToValidateOn))
throw new Error("Value to check field value 'in' or 'any' is not an iterable.");
return valueToValidateOn.includes(fieldValue);
case "not-in":
if (!valueToValidateOn || !Array.isArray(valueToValidateOn))
throw new Error(
"Value to check field value 'not-in' is not an iterable."
);
return !valueToValidateOn.includes(fieldValue);
case "not":
return fieldValue != valueToValidateOn;
case "includes":
return fieldValue.includes(valueToValidateOn);
case "not-includes":
return !fieldValue.includes(valueToValidateOn);
default:
throw new Error("Invalid/Unsupported operation.");
}
}
/**
* Function to check if a row in a table matches any one of the specified operations or not. I.E: "Or" Operations.
* @param { Object } row - The object representing a row in a database table.
* @param { Array } subFilters - The filters to check the row against.
* @return { Boolean }
*/
function verifyOrOperation(row, filters) {
let anyConditionsMatch = false;
for (let filter in filters) {
if (filter in row) {
let valueToCompare = row[filter];
if (filter.includes(".")) valueToCompare = getNestedField(row, filter);
if (validateValueAgainstFilter(filters[filter], valueToCompare)) {
anyConditionsMatch = true;
break; // We only need one match to verify an OR operation.
}
}
}
return anyConditionsMatch;
}
/**
* Function to get nested fields from objects, using strings like: 'parentField.childField'.
* @param { Object } row - The object representing a row in a database table.
* @param { String } fieldToGet - The string representation of the field to get from the database.
* @return { any }
*/
function getNestedField(row, fieldToGet) {
let level = fieldToGet.split(".");
let currentRowState = { ...row };
for (var i = 0; i < level.length; i++) {
if (currentRowState[level[i]]) currentRowState = currentRowState[level[i]];
else return null;
}
return currentRowState;
}
/**
* Function to validate value against a regex or just plain value.
* @param { any } filterValue - The value of the filter to validate against.
* @param { any } value - The value to check for equality.
* @return { Boolean }
*/
function validateValueAgainstFilter(filterValue, value) {
if (filterValue instanceof RegExp) return filterValue.test(value);
else return filterValue == value;
}
module.exports = { db };