/**
* LocalTable: Provides a database-table-like API.
*
* @module localtable/table
*/
"use strict";
import {
isString,
isInteger,
isFloat,
isBool,
isObject,
isFunction,
} from "./validation.js";
/**
* A class representing a table of similar rows.
*/
class LocalTable {
/** Mapping of field types to validation functions */
fieldTypes = {
"str": isString,
"int": isInteger,
"float": isFloat,
"timestamp": isInteger,
"bool": isBool,
"obj": null,
};
/** The available lookup types for basic filtering */
lookupTypes = [
"=",
">",
">=",
"<",
"<=",
"!=",
];
/** The name of the `id` in a row's data */
idField = "id";
/**
* Creates a new `LocalTable` instance.
* @param {Storage} storage - Reference to the `Storage`-like object that
* will keep the data.
* @param {string} tableName - The name of the table.
* @param {object} options - The options for instantiating the table.
*/
constructor(storage, tableName, options) {
this.storage = storage;
this.tableName = tableName;
this._fields = options["fields"] || [];
this._cache_ids = null;
// Ensure the table exists.
this.create();
};
_tableListName() {
return `${this.tableName}_list`;
};
_getIds() {
if(this._cache_ids === null) {
let listName = this._tableListName();
let listData = this.storage.getItem(listName);
if(! listData) {
this.create();
} else {
this._cache_ids = JSON.parse(listData);
}
}
return this._cache_ids;
};
_setIds() {
if(this._cache_ids === null) {
// We haven't tried to access any keys yet.
this._cache_ids = [];
}
let listName = this._tableListName();
const allIds = JSON.stringify(this._cache_ids);
this.storage.setItem(listName, allIds);
};
_detailName(id) {
return `${this.tableName}_detail_${id}`;
};
_pushNewId(id) {
if(this._cache_ids === null) {
this._cache_ids = [];
}
this._cache_ids.push(id);
this._setIds();
};
_serializeData(data) {
// We need to make a copy, so that we don't alter-by-reference the
// user's data.
const detailData = {};
for(const key of Object.keys(data)) {
if(key === this.idField) {
continue;
}
if(data.hasOwnProperty(key)) {
detailData[key] = data[key];
}
}
return JSON.stringify(detailData);
};
_deserializeData(id, data) {
const detailData = JSON.parse(data);
detailData[this.idField] = id;
return detailData;
};
/**
* Creates the table (if not already present).
* @return {null}
*/
create() {
let listName = this._tableListName();
if(! this.storage.getItem(listName)) {
this._setIds();
};
};
/**
* Drops the table & all rows from the storage.
* @return {null}
*/
drop() {
// Delete all the detail records first.
let allIds = this._getIds();
for(const id of allIds) {
const actualName = this._detailName(id);
this.storage.removeItem(actualName);
}
// Then delete the table.
let listName = this._tableListName();
this.storage.removeItem(listName);
// And reset the internal IDs.
this._cache_ids = null;
};
_defaultFiltering(filterBy, detailData) {
let matched = [true];
for(const fieldName of Object.keys(filterBy)) {
if(! detailData.hasOwnProperty(fieldName)) {
continue;
}
const currentValue = detailData[fieldName];
const lookupData = filterBy[fieldName];
for(const comparison of Object.keys(lookupData)) {
if(this.lookupTypes.indexOf(comparison) < 0) {
throw new Error(`Invalid lookup type '${comparison}' provided!`);
}
let desiredValue = lookupData[comparison];
switch(comparison) {
case "=":
matched.push(currentValue === desiredValue);
break;
case ">":
matched.push(currentValue > desiredValue);
break;
case ">=":
matched.push(currentValue >= desiredValue);
break;
case "<":
matched.push(currentValue < desiredValue);
break;
case "<=":
matched.push(currentValue <= desiredValue);
break;
case "!=":
matched.push(currentValue !== desiredValue);
break;
default:
throw new Error(`Unhandled lookup type '${comparison}'!`);
}
}
}
return matched.every((bit) => bit === true);
};
_filter(filterBy) {
let allIds = this._getIds();
let allData = [];
for(const id of allIds) {
const actualName = this._detailName(id);
const rawData = this.storage.getItem(actualName);
if(! rawData) {
console.log(`Couldn't find detail data for ${actualName}! Skipping...`);
}
const detailData = this._deserializeData(id, rawData);
if(filterBy !== undefined) {
if(! isFunction(filterBy)) {
// Use the default filtering.
if(! this._defaultFiltering(filterBy, detailData)) {
continue;
}
} else {
// We've got a custom callable.
if(! filterBy(detailData)) {
continue;
}
}
}
allData.push(detailData);
}
return allData;
}
/**
* Returns all rows found in the table.
* @return {array} An array of objects for all the rows
*/
all() {
return this._filter();
};
/**
* Returns a count of the number of rows in the table.
* @return {integer} How many rows are in the table
*/
count() {
let allIds = this._getIds();
return allIds.length;
};
/**
* Checks if a row is in the table.
* @param {any} id - The identifier of the row. Typically an integer, but can
* be a string/UUID/etc.
* @return {boolean} True if present, else False.
*/
exists(id) {
try {
// This is a little wasteful, as we're parsing the data then just
// throwing it out. But whatevs.
this.get(id);
return true;
} catch (err) {
return false;
}
};
/**
* Fetches a specific row from the table.
* @param {any} id - The identifier of the row. Typically an integer, but can
* be a string/UUID/etc.
* @throws If the provided ID is not present in the table.
* @return {object} The detail data for the row
*/
get(id) {
const actualName = this._detailName(id);
let detailData = this.storage.getItem(actualName);
if(! detailData) {
throw new Error(`Couldn't find data for '${id}'.`);
}
return this._deserializeData(id, detailData);
};
_validate(data) {
const errors = [];
for(const fieldAttrs of this._fields) {
const fieldName = fieldAttrs["name"];
// Check if it's missing
if(! data.hasOwnProperty(fieldName)) {
// If it's not present & there's a default, use that instead.
if(fieldAttrs.hasOwnProperty("default")) {
data[fieldName] = fieldAttrs["default"];
} else if(fieldAttrs["required"] === false) {
// It's missing & not required. Don't bother trying to validate.
} else {
errors.push(`Missing data for ${fieldName}`);
}
continue;
}
// Check its type.
let fieldType = "str";
if(fieldAttrs.hasOwnProperty("type")) {
fieldType = fieldAttrs["type"];
}
if(! this.fieldTypes.hasOwnProperty(fieldType)) {
errors.push(`Invalid field type provided: ${fieldType}`);
continue;
}
let validator = this.fieldTypes[fieldType];
if(validator !== null) {
if(! validator(data[fieldName])) {
errors.push(`Invalid data type provided for '${fieldName}': ${data[fieldName]}`);
continue;
}
}
// TODO: Maybe length/range checks here in the future?
}
return errors;
};
/**
* Inserts a new row into the table.
* @param {any} id - The identifier of the row. Typically an integer, but can
* be a string/UUID/etc.
* @param {object} data - The field data for the row
* @throws If the id is already present in the table or the fields fail
* to validate
* @return {null}
*/
insert(id, data) {
if(this.exists(id)) {
throw new Error(`Data is already present for '${id}'!`);
}
// Check for validation errors.
const errors = this._validate(data);
if(errors.length > 0) {
throw new Error(`Invalid data! ${errors}`);
}
// If we're good, update the storage.
const actualName = this._detailName(id);
const actualData = this._serializeData(data);
this.storage.setItem(actualName, actualData);
// Then append it onto the list.
this._pushNewId(id);
};
/**
* Updates an existing row (or inserts a new row if not present) into the
* table.
* @param {any} id - The identifier of the row. Typically an integer, but can
* be a string/UUID/etc.
* @param {object} data - The changed field data for the row
* @throws If the fields fail to validate
* @return {null}
*/
update(id, newData) {
const actualName = this._detailName(id);
let found = false;
let currentData;
// Fetch the current data, or assume an empty row.
try {
currentData = this.get(id);
found = true;
} catch (err) {
currentData = {};
}
// Copy over the updated data.
for(const fieldName of Object.keys(newData)) {
currentData[fieldName] = newData[fieldName];
}
// Check for validation errors.
const errors = this._validate(currentData);
if(errors.length > 0) {
throw new Error(`Invalid data! ${errors}`);
}
// If we're good, update the storage.
const actualData = this._serializeData(currentData);
this.storage.setItem(actualName, actualData);
// Then, if new, append it onto the list.
if(! found) {
this._pushNewId(id);
}
};
/**
* Deletes a row from the table.
* @param {any} id - The identifier of the row. Typically an integer, but can
* be a string/UUID/etc.
* @return {null}
*/
delete(id) {
const actualName = this._detailName(id);
this.storage.removeItem(actualName);
// Then fix the cached IDs.
this._cache_ids = this._cache_ids.filter(currentId => currentId !== id);
this._setIds();
};
/**
* Returns a filtered set of rows from the table.
* @param {object|function} filterBy - Either a plain object of filters
* or a user-defined function to do the filtering.
* @throws If invalid fields or lookup types are provided
* @return {array} An array of objects of matched rows
*/
filter(filterBy) {
return this._filter(filterBy);
};
}
export {
LocalTable,
};