enmap
Version:
A simple database wrapper to make sqlite database interactions much easier for beginners, with additional array helper methods.
1,164 lines (1,085 loc) • 43.2 kB
JavaScript
import {
get as _get,
set as _set,
isNil,
isFunction,
isArray,
isObject,
cloneDeep,
merge,
} from 'lodash-es';
import { stringify, parse } from 'better-serialize';
import onChange from 'on-change';
// Custom error codes with stack support.
import Err from './error.js';
// Native imports
import { existsSync, readFileSync, mkdirSync } from 'fs';
import { resolve, sep } from 'path';
// Package.json
const pkgdata = JSON.parse(readFileSync('./package.json', 'utf8'));
import Database from 'better-sqlite3';
const NAME_REGEX = /^([\w-]+)$/;
/**
* A simple, synchronous, fast key/value storage build around better-sqlite3.
* Contains extra utility methods for managing arrays and objects.
* @class Enmap
*/
class Enmap {
#name;
#db;
#inMemory;
#autoEnsure;
#ensureProps;
#serializer;
#deserializer;
#changedCB;
/**
* Initializes a new Enmap, with options.
* @param {Object} options Options for the enmap. See https://enmap.alterion.dev/usage#enmap-options for details.
* @param {string} options.name The name of the enmap. Represents its table name in sqlite. Unless inMemory is set to true, the enmap will be persisted to disk.
* @param {string} [options.dataDir] Optional. Defaults to `./data`. Determines where the sqlite files will be stored. Can be relative
* (to your project root) or absolute on the disk. Windows users , remember to escape your backslashes!
* *Note*: Enmap will not automatically create the folder if it is set manually, so make sure it exists before starting your code!
* @param {boolean} [options.ensureProps] Optional. defaults to `true`. If enabled and the value in the enmap is an object, using ensure() will also ensure that
* every property present in the default object will be added to the value, if it's absent. See ensure API reference for more information.
* @param {*} [options.autoEnsure] Optional. default is disabled. When provided a value, essentially runs ensure(key, autoEnsure) automatically so you don't have to.
* This is especially useful on get(), but will also apply on set(), and any array and object methods that interact with the database.
* @param {Function} [options.serializer] Optional. If a function is provided, it will execute on the data when it is written to the database.
* This is generally used to convert the value into a format that can be saved in the database, such as converting a complete class instance to just its ID.
* This function may return the value to be saved, or a promise that resolves to that value (in other words, can be an async function).
* @param {Function} [options.deserializer] Optional. If a function is provided, it will execute on the data when it is read from the database.
* This is generally used to convert the value from a stored ID into a more complex object.
* This function may return a value, or a promise that resolves to that value (in other words, can be an async function).
*
* @param {boolean} [options.inMemory] Optional. If set to true, the enmap will be in-memory only, and will not write to disk. Useful for temporary stores.
*
* @param {Object} [options.sqliteOptions] Optional. An object of options to pass to the better-sqlite3 Database constructor.
* @example
* const Enmap = require("enmap");
* // Named, Persistent enmap
* const myEnmap = new Enmap({ name: "testing" });
*
* // Memory-only enmap
* const memoryEnmap = new Enmap({ inMemory: true });
*
* // Enmap that automatically assigns a default object when getting or setting anything.
* const autoEnmap = new Enmap({name: "settings", autoEnsure: { setting1: false, message: "default message"}})
*/
constructor(options) {
this.#inMemory = options.inMemory ?? false;
if (options.name === '::memory::') {
this.#inMemory = true;
console.warn(
'Using ::memory:: as a name is deprecated and will be removed in the future. Use { inMemory: true } instead.',
);
}
this.#ensureProps = options.ensureProps ?? true;
this.#serializer = options.serializer ? options.serializer : (data) => data;
this.#deserializer = options.deserializer
? options.deserializer
: (data) => data;
this.#autoEnsure = options.autoEnsure;
if (this.#inMemory) {
this.#db = new Database(':memory:');
this.#name = 'MemoryEnmap';
} else {
this.#name = options.name;
if (!options.dataDir) {
if (!existsSync('./data')) {
mkdirSync('./data');
}
}
const dataDir = resolve(process.cwd(), options.dataDir || 'data');
this.#db = new Database(
`${dataDir}${sep}enmap.sqlite`,
options.sqliteOptions,
);
}
if (!this.#db) {
throw new Err('Database Could Not Be Opened', 'EnmapDBConnectionError');
}
// Check if enmap by this name is in the sqlite master table
const table = this.#db
.prepare(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?;",
)
.get(this.#name);
// This is a first init, create everything!
if (!table['count(*)']) {
// Create base table
this.#db
.prepare(
`CREATE TABLE ${this.#name} (key text PRIMARY KEY, value text)`,
)
.run();
// Define table properties : sync and write-ahead-log
this.#db.pragma('synchronous = 1');
this.#db.pragma('journal_mode = wal');
// Create autonum table
this.#db
.prepare(
`CREATE TABLE IF NOT EXISTS 'internal::autonum' (enmap TEXT PRIMARY KEY, lastnum INTEGER)`,
)
.run();
}
process.on('exit', () => {
this.#db.close();
});
}
// MARK: Set Methods
/**
* Sets a value in Enmap. If the key already has a value, overwrites the data (or the value in a path, if provided).
* @param {string} key Required. The location in which the data should be saved.
* @param {*} value Required. The value to write.
* Values must be serializable, which is done through (better-serialize)[https://github.com/RealShadowNova/better-serialize]
* If the value is not directly serializable, please use a custom serializer/deserializer.
* @param {string} path Optional. The path to the property to modify inside the value object or array.
* Should be a path with dot notation, such as "prop1.subprop2.subprop3"
* @example
* // Direct Value Examples
* enmap.set('simplevalue', 'this is a string');
* enmap.set('isEnmapGreat', true);
* enmap.set('TheAnswer', 42);
* enmap.set('IhazObjects', { color: 'black', action: 'paint', desire: true });
* enmap.set('ArraysToo', [1, "two", "tree", "foor"])
*
* // Settings Properties
* enmap.set('IhazObjects', 'blue', 'color'); //modified previous object
* enmap.set('ArraysToo', 'three', 2); // changes "tree" to "three" in array.
*/
set(key, value, path) {
this.#keycheck(key);
let data = this.get(key);
const oldValue = cloneDeep(data);
if (!isNil(path)) {
if (isNil(data)) data = {};
_set(data, path, value);
} else {
data = value;
}
if (isFunction(this.#changedCB)) this.#changedCB(key, oldValue, data);
this.#set(key, data);
}
/**
* Update an existing object value in Enmap by merging new keys. **This only works on objects**, any other value will throw an error.
* Heavily inspired by setState from React's class components.
* This is very useful if you have many different values to update and don't want to have more than one .set(key, value, prop) lines.
* @param {string} key The key of the object to update.
* @param {*} valueOrFunction Either an object to merge with the existing value, or a function that provides the existing object
* and expects a new object as a return value. In the case of a straight value, the merge is recursive and will add any missing level.
* If using a function, it is your responsibility to merge the objects together correctly.
* @example
* // Define an object we're going to update
* enmap.set("obj", { a: 1, b: 2, c: 3 });
*
* // Direct merge
* enmap.update("obj", { d: 4, e: 5 });
* // obj is now { a: 1, b: 2, c: 3, d: 4, e: 5 }
*
* // Functional update
* enmap.update("obj", (previous) => ({
* ...obj,
* f: 6,
* g: 7
* }));
* // this example takes heavy advantage of the spread operators.
* // More info: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
* @returns {*} The modified (merged) value.
*/
update(key, valueOrFunction) {
this.#keycheck(key);
this.#check(key, ['Object']);
const data = this.get(key);
const fn = isFunction(valueOrFunction)
? valueOrFunction
: () => merge(data, valueOrFunction);
const merged = fn(data);
this.#set(key, merged);
return merged;
}
/**
* Retrieves a value from the enmap, using its key.
* @param {string} key The key to retrieve from the enmap.
* @param {string} path Optional. The property to retrieve from the object or array.
* Should be a path with dot notation, such as "prop1.subprop2.subprop3"
* @example
* const myKeyValue = enmap.get("myKey");
* console.log(myKeyValue);
*
* const someSubValue = enmap.get("anObjectKey", "someprop.someOtherSubProp");
* @return {*} The parsed value for this key.
*/
get(key, path) {
this.#keycheck(key);
if (!isNil(this.#autoEnsure) && !this.has(key)) {
this.#set(key, this.#autoEnsure);
}
const data = this.#db
.prepare(`SELECT value FROM ${this.#name} WHERE key = ?`)
.get(key);
const parsed = data ? this.#parse(data.value) : null;
if (isNil(parsed)) return null;
if (path) {
this.#check(key, ['Object']);
return _get(parsed, path);
}
return parsed;
}
/**
* Returns an observable object. Modifying this object or any of its properties/indexes/children
* will automatically save those changes into enmap. This only works on
* objects and arrays, not "basic" values like strings or integers.
* @param {*} key The key to retrieve from the enmap.
* @param {string} path Optional. The property to retrieve from the object or array.
* @return {*} The value for this key.
*/
observe(key, path) {
this.#check(key, ['Object', 'Array'], path);
const data = this.get(key, path);
const proxy = onChange(data, () => {
this.set(key, proxy, path);
});
return proxy;
}
/**
* Get the number of key/value pairs saved in the enmap.
* @readonly
* @returns {number} The number of elements in the enmap.
*/
get size() {
const data = this.#db
.prepare(`SELECT count(*) FROM '${this.#name}';`)
.get();
return data['count(*)'];
}
// Aliases are cheap, why not?
get count() {
return this.size;
}
get length() {
return this.size;
}
/**
* Get all the keys of the enmap as an array.
* @returns {Array<string>} An array of all the keys in the enmap.
*/
keys() {
const stmt = this.#db.prepare(`SELECT key FROM ${this.#name}`);
const indexes = [];
for (const row of stmt.iterate()) {
indexes.push(row.key);
}
return indexes;
}
indexes() {
return this.keys();
}
/**
* Get all the values of the enmap as an array.
* @returns {Array<*>} An array of all the values in the enmap.
*/
values() {
const stmt = this.#db.prepare(`SELECT value FROM ${this.#name}`);
const values = [];
for (const row of stmt.iterate()) {
values.push(this.#parse(row.value));
}
return values;
}
/**
* Get all entries of the enmap as an array, with each item containing the key and value.
* @returns {Array<Array<*,*>>} An array of arrays, with each sub-array containing two items, the key and the value.
*/
entries() {
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
const entries = [];
for (const row of stmt.iterate()) {
entries.push([row.key, this.#parse(row.value)]);
}
return entries;
}
/**
* Get the better-sqlite3 database object. Useful if you want to directly query or interact with the
* underlying SQLite database. Use at your own risk, as errors here might cause loss of data or corruption!
* @return {Database}
*/
get db() {
return this.#db;
}
/**
* Generates an automatic numerical key for inserting a new value.
* This is a "weak" method, it ensures the value isn't duplicated, but does not
* guarantee it's sequential (if a value is deleted, another can take its place).
* Useful for logging, actions, items, etc - anything that doesn't already have a unique ID.
* @readonly
* @example
* enmap.set(enmap.autonum, "This is a new value");
* @return {number} The generated key number.
*/
get autonum() {
let result = this.#db
.prepare("SELECT lastnum FROM 'internal::autonum' WHERE enmap = ?")
.get(this.#name);
let lastnum = result ? parseInt(result.lastnum, 10) : 0;
lastnum++;
this.#db
.prepare(
"INSERT OR REPLACE INTO 'internal::autonum' (enmap, lastnum) VALUES (?, ?)",
)
.run(this.#name, lastnum);
return lastnum.toString();
}
/**
* Push to an array value in Enmap.
* @param {string} key Required. The key of the array element to push to in Enmap.
* @param {*} value Required. The value to push to the array.
* @param {string} path Optional. The path to the property to modify inside the value object or array.
* Should be a path with dot notation, such as "prop1.subprop2.subprop3"
* @param {boolean} allowDupes Optional. Allow duplicate values in the array (default: false).
* @example
* // Assuming
* enmap.set("simpleArray", [1, 2, 3, 4]);
* enmap.set("arrayInObject", {sub: [1, 2, 3, 4]});
*
* enmap.push("simpleArray", 5); // adds 5 at the end of the array
* enmap.push("arrayInObject", "five", "sub"); // adds "five" at the end of the sub array
*/
push(key, value, path, allowDupes = false) {
this.#keycheck(key);
this.#check(key, ['Array', 'Object']);
const data = this.get(key, path);
if (!isArray(data))
throw new Err('Key does not point to an array', 'EnmapPathError');
if (!allowDupes && data.includes(value)) return;
data.push(value);
this.set(key, data, path);
}
// MARK: Math Methods
/**
* Executes a mathematical operation on a value and saves it in the enmap.
* @param {string} key The enmap key on which to execute the math operation.
* @param {string} operation Which mathematical operation to execute. Supports most
* math ops: =, -, *, /, %, ^, and english spelling of those operations.
* @param {number} operand The right operand of the operation.
* @param {string} path Optional. The property path to execute the operation on, if the value is an object or array.
* @example
* // Assuming
* points.set("number", 42);
* points.set("numberInObject", {sub: { anInt: 5 }});
*
* points.math("number", "/", 2); // 21
* points.math("number", "add", 5); // 26
* points.math("number", "modulo", 3); // 2
* points.math("numberInObject", "+", 10, "sub.anInt");
* @returns {number} The updated value after the operation
*/
math(key, operation, operand, path) {
this.#keycheck(key);
this.#check(key, ['Number'], path);
const data = this.get(key, path);
const updatedValue = this.#math(data, operation, operand);
this.set(key, updatedValue, path);
return updatedValue;
}
/**
* Increments a key's value or property by 1. Value must be a number, or a path to a number.
* @param {string} key The enmap key where the value to increment is stored.
* @param {string} path Optional. The property path to increment, if the value is an object or array.
* @example
* // Assuming
* points.set("number", 42);
* points.set("numberInObject", {sub: { anInt: 5 }});
*
* points.inc("number"); // 43
* points.inc("numberInObject", "sub.anInt"); // {sub: { anInt: 6 }}
* @returns {number} The udpated value after incrementing.
*/
inc(key, path = null) {
this.#keycheck(key);
this.#check(key, ['Number'], path);
const data = this.get(key, path);
this.set(key, data + 1, path);
return this;
}
/**
* Decrements a key's value or property by 1. Value must be a number, or a path to a number.
* @param {string} key The enmap key where the value to decrement is stored.
* @param {string} path Optional. The property path to decrement, if the value is an object or array.
* @example
* // Assuming
* points.set("number", 42);
* points.set("numberInObject", {sub: { anInt: 5 }});
*
* points.dec("number"); // 41
* points.dec("numberInObject", "sub.anInt"); // {sub: { anInt: 4 }}
* @returns {Enmap} The enmap.
*/
dec(key, path = null) {
this.#keycheck(key);
this.#check(key, ['Number'], path);
const data = this.get(key, path);
this.set(key, data - 1, path);
return this;
}
/**
* Returns the key's value, or the default given, ensuring that the data is there.
* This is a shortcut to "if enmap doesn't have key, set it, then get it" which is a very common pattern.
* @param {string} key Required. The key you want to make sure exists.
* @param {*} defaultValue Required. The value you want to save in the database and return as default.
* @param {string} path Optional. If presents, ensures both the key exists as an object, and the full path exists.
* Should be a path with dot notation, such as "prop1.subprop2.subprop3"
* @example
* // Simply ensure the data exists (for using property methods):
* enmap.ensure("mykey", {some: "value", here: "as an example"});
* enmap.has("mykey"); // always returns true
* enmap.get("mykey", "here") // returns "as an example";
*
* // Get the default value back in a variable:
* const settings = mySettings.ensure("1234567890", defaultSettings);
* console.log(settings) // enmap's value for "1234567890" if it exists, otherwise the defaultSettings value.
* @return {*} The value from the database for the key, or the default value provided for a new key.
*/
ensure(key, defaultValue, path) {
this.#keycheck(key);
if (!isNil(this.#autoEnsure)) {
if (!isNil(defaultValue))
process.emitWarning(
`Saving "${key}" autoEnsure value was provided for this enmap but a default value has also been provided. The defaultValue will be ignored, autoEnsure value is used instead.`,
);
defaultValue = this.#autoEnsure;
}
const clonedDefault = cloneDeep(defaultValue);
if (!isNil(path)) {
if (this.has(key, path)) return this.get(key, path);
if (this.#ensureProps) this.ensure(key, {});
this.set(key, clonedDefault, path);
return clonedDefault;
}
if (this.#ensureProps && isObject(this.get(key))) {
if (!isObject(clonedDefault))
throw new Err(
`Default value for "${key}" in enmap "${
this.#name
}" must be an object when merging with an object value.`,
'EnmapArgumentError',
);
const merged = merge(clonedDefault, this.get(key));
this.set(key, merged);
return merged;
}
if (this.has(key)) return this.get(key);
this.set(key, clonedDefault);
return clonedDefault;
}
/**
* Returns whether or not the key exists in the Enmap.
* @param {string} key Required. The key of the element to add to The Enmap or array.
* @param {string} path Optional. The property to verify inside the value object or array.
* Should be a path with dot notation, such as "prop1.subprop2.subprop3"
* @example
* if(enmap.has("myKey")) {
* // key is there
* }
*
* if(!enmap.has("myOtherKey", "oneProp.otherProp.SubProp")) return false;
* @returns {boolean}
*/
has(key) {
this.#keycheck(key);
const data = this.#db
.prepare(`SELECT count(*) FROM ${this.#name} WHERE key = ?`)
.get(key);
return data['count(*)'] > 0;
}
/**
* Performs Array.includes() on a certain enmap value. Works similar to
* [Array.includes()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes).
* @param {string} key Required. The key of the array to check the value of.
* @param {string|number} value Required. The value to check whether it's in the array.
* @param {string} path Optional. The property to access the array inside the value object or array.
* Should be a path with dot notation, such as "prop1.subprop2.subprop3"
* @return {boolean} Whether the array contains the value.
*/
includes(key, value, path) {
this.#keycheck(key);
this.#check(key, ['Array'], path);
const data = this.get(key, path);
return data.includes(value);
}
/**
* Deletes a key in the Enmap.
* @param {string} key Required. The key of the element to delete from The Enmap.
* @param {string} path Optional. The name of the property to remove from the object.
* Should be a path with dot notation, such as "prop1.subprop2.subprop3"
*/
delete(key, path) {
this.#keycheck(key);
if (path) {
this.#check(key, ['Object']);
const data = this.get(key);
_set(data, path, undefined);
this.set(key, data);
} else {
this.#db.prepare(`DELETE FROM ${this.#name} WHERE key = ?`).run(key);
}
}
/**
* Deletes everything from the enmap.
* @returns {void}
*/
clear() {
this.#db.prepare(`DELETE FROM ${this.#name}`).run();
}
/**
* Remove a value in an Array or Object element in Enmap. Note that this only works for
* values, not keys. Note that only one value is removed, no more. Arrays of objects must use a function to remove,
* as full object matching is not supported.
* @param {string} key Required. The key of the element to remove from in Enmap.
* @param {*|Function} val Required. The value to remove from the array or object. OR a function to match an object.
* If using a function, the function provides the object value and must return a boolean that's true for the object you want to remove.
* @param {string} path Optional. The name of the array property to remove from.
* Should be a path with dot notation, such as "prop1.subprop2.subprop3".
* If not presents, removes directly from the value.
* @example
* // Assuming
* enmap.set('array', [1, 2, 3])
* enmap.set('objectarray', [{ a: 1, b: 2, c: 3 }, { d: 4, e: 5, f: 6 }])
*
* enmap.remove('array', 1); // value is now [2, 3]
* enmap.remove('objectarray', (value) => value.e === 5); // value is now [{ a: 1, b: 2, c: 3 }]
*/
remove(key, val, path) {
this.#keycheck(key);
this.#check(key, ['Array', 'Object']);
const data = this.get(key, path);
const criteria = isFunction(val) ? val : (value) => val === value;
const index = data.findIndex(criteria);
if (index > -1) {
data.splice(index, 1);
}
this.set(key, data, path);
}
/**
* Exports the enmap data to stringified JSON format.
* **__WARNING__**: Does not work on memory enmaps containing complex data!
* @returns {string} The enmap data in a stringified JSON format.
*/
export() {
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
const entries = [];
for (const row of stmt.iterate()) {
entries.push(row);
}
return stringify({
name: this.#name,
exportDate: Date.now(),
version: pkgdata.version,
keys: entries,
});
}
/**
* Import an existing json export from enmap. This data must have been exported from enmap,
* and must be from a version that's equivalent or lower than where you're importing it.
* (This means Enmap 5 data is compatible in Enmap 6).
* @param {string} data The data to import to Enmap. Must contain all the required fields provided by an enmap export().
* @param {boolean} overwrite Defaults to `true`. Whether to overwrite existing key/value data with incoming imported data
* @param {boolean} clear Defaults to `false`. Whether to clear the enmap of all data before importing
* (**__WARNING__**: Any existing data will be lost! This cannot be undone.)
*/
import(data, overwrite = true, clear = false) {
let parsedData;
try {
parsedData = JSON.parse(data);
} catch (e) {
throw new Err('Data provided is not valid JSON', 'EnmapDataError');
}
if (isNil(parsedData))
throw new Err(
`No data provided for import() in "${this.#name}"`,
'EnmapImportError',
);
if (clear) this.clear();
for (const entry of parsedData.keys) {
const { key, value } = entry;
if (!overwrite && this.has(key)) continue;
this.#db
.prepare(
`INSERT OR REPLACE INTO ${this.#name} (key, value) VALUES (?, ?)`,
)
.run(key, value);
}
}
/**
* Initialize multiple Enmaps easily.
* @param {Array<string>} names Array of strings. Each array entry will create a separate enmap with that name.
* @param {Object} options Options object to pass to each enmap, excluding the name..
* @example
* // Using local variables.
* const Enmap = require('enmap');
* const { settings, tags, blacklist } = Enmap.multi(['settings', 'tags', 'blacklist']);
*
* // Attaching to an existing object (for instance some API's client)
* const Enmap = require("enmap");
* Object.assign(client, Enmap.multi(["settings", "tags", "blacklist"]));
*
* @returns {Object} An array of initialized Enmaps.
*/
static multi(names, options) {
if (!names.length) {
throw new Err(
'"names" argument must be an array of string names.',
'EnmapTypeError',
);
}
const enmaps = {};
for (const name of names) {
enmaps[name] = new Enmap({ ...options, name });
}
return enmaps;
}
/**
* Obtains random value(s) from this Enmap. This relies on {@link Enmap#array}.
* @param {number} [count] Number of values to obtain randomly
* @returns {*|Array<*>} The single value if `count` is undefined,
* or an array of values of `count` length
*/
random(count = 1) {
const stmt = this.#db
.prepare(`SELECT key, value FROM ${this.#name} ORDER BY RANDOM() LIMIT ?`)
.bind(count);
const results = [];
for (const row of stmt.iterate()) {
results.push([row.key, this.#parse(row.value)]);
}
return results;
}
/**
* Obtains random key(s) from this Enmap. This relies on {@link Enmap#keyArray}
* @param {number} [count] Number of keys to obtain randomly
* @returns {*|Array<*>} The single key if `count` is undefined,
* or an array of keys of `count` length
*/
randomKey(count = 1) {
const stmt = this.#db
.prepare(`SELECT key FROM ${this.#name} ORDER BY RANDOM() LIMIT ?`)
.bind(count);
const results = [];
for (const row of stmt.iterate()) {
results.push(row.key);
}
return results;
}
/**
* Similar to
* [Array.every()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every).
* Supports either a predicate function or a value to compare.
* Returns true only if the predicate function returns true for all elements in the array (or the value is strictly equal in all elements).
* @param {Function | string} valueOrFunction Function used to test (should return a boolean), or a value to compare.
* @param {string} [path] Required if the value is an object. The path to the property to compare with.
* @returns {boolean}
*/
every(valueOrFunction, path) {
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
for (const row of stmt.iterate()) {
const parsed = this.#parse(row.value);
const data = isNil(path) ? parsed : _get(parsed, path);
if (isFunction(valueOrFunction)) {
if (!valueOrFunction(data, row.key)) {
return false;
}
} else {
if (valueOrFunction !== data) {
return false;
}
}
}
return true;
}
/**
* Similar to
* [Array.some()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some).
* Supports either a predicate function or a value to compare.
* Returns true if the predicate function returns true for at least one element in the array (or the value is equal in at least one element).
* @param {Function | string} valueOrFunction Function used to test (should return a boolean), or a value to compare.
* @param {string} [path] Required if the value is an object. The path to the property to compare with.
* @returns {Array}
*/
some(valueOrFunction, path) {
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
for (const row of stmt.iterate()) {
const parsed = this.#parse(row.value);
const data = isNil(path) ? parsed : _get(parsed, path);
if (isFunction(valueOrFunction)) {
if (valueOrFunction(data, row.key)) {
return true;
}
} else {
if (valueOrFunction === data) {
return true;
}
}
}
return false;
}
/**
* Similar to
* [Array.map()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map).
* Returns an array of the results of applying the callback to all elements.
* @param {Function | string} pathOrFn A function that produces an element of the new Array, or a path to the property to map.
* @returns {Array}
*/
map(pathOrFn) {
this.#db.aggregate('map', {
start: [],
step: (accumulator, value, key) => {
const parsed = this.#parse(value);
if (isFunction(pathOrFn)) {
accumulator.push(pathOrFn(parsed, key));
} else {
accumulator.push(_get(parsed, pathOrFn));
}
return accumulator;
},
result: (accumulator) => JSON.stringify(accumulator),
});
const results = this.#db
.prepare(`SELECT map(value, key) FROM ${this.#name}`)
.pluck()
.get();
return JSON.parse(results);
}
/**
* Searches for a single item where its specified property's value is identical to the given value
* (`item[prop] === value`), or the given function returns a truthy value. In the latter case, this is similar to
* [Array.find()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find).
* @param {string|Function} pathOrFn The path to the value to test against, or the function to test with
* @param {*} [value] The expected value - only applicable and required if using a property for the first argument
* @returns {*}
* @example
* enmap.find('username', 'Bob');
* @example
* enmap.find(val => val.username === 'Bob');
*/
find(pathOrFn, value) {
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
for (const row of stmt.iterate()) {
const parsed = this.#parse(row.value);
const func = isFunction(pathOrFn)
? pathOrFn
: (v) => value === _get(v, pathOrFn);
if (func(parsed, row.key)) {
return parsed;
}
}
return null;
}
/**
* Searches for the key of a single item where its specified property's value is identical to the given value
* (`item[prop] === value`), or the given function returns a truthy value. In the latter case, this is similar to
* [Array.findIndex()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findIndex).
* @param {string|Function} pathOrFn The path to the value to test against, or the function to test with
* @param {*} [value] The expected value - only applicable and required if using a property for the first argument
* @returns {string|number}
* @example
* enmap.findIndex('username', 'Bob');
* @example
* enmap.findIndex(val => val.username === 'Bob');
*/
findIndex(pathOrFn, value) {
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
for (const row of stmt.iterate()) {
const parsed = this.#parse(row.value);
const func = isFunction(pathOrFn)
? pathOrFn
: (v) => value === _get(v, pathOrFn);
if (func(parsed, row.key)) {
return row.key;
}
}
return null;
}
/**
* Similar to
* [Array.reduce()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce).
* @param {Function} predicate Function used to reduce, taking three arguments; `accumulator`, `currentValue`, `currentKey`.
* @param {*} [initialValue] Starting value for the accumulator
* @returns {*}
*/
reduce(predicate, initialValue) {
this.#db.aggregate('reduce', {
start: initialValue,
step: (accumulator, currentValue, key) =>
predicate(accumulator, this.#parse(currentValue), key),
});
return this.#db
.prepare(`SELECT reduce(value, key) FROM ${this.#name}`)
.pluck()
.get();
}
/**
* Similar to
* [Array.filter()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter).
* Returns an array of values where the given function returns true for that value.
* Alternatively you can provide a value and path to filter by using exact value matching.
* @param {Function} pathOrFn The path to the value to test against, or the function to test with.
* If using a function, this function should return a boolean.
* @param {string} [value] Value to use as `this` when executing function
* @returns {Enmap}
*/
filter(pathOrFn, value) {
this.#db.aggregate('filter', {
start: [],
step: (accumulator, currentValue, key) => {
const parsed = this.#parse(currentValue);
if (isFunction(pathOrFn)) {
if (pathOrFn(parsed, key)) {
accumulator.push(parsed);
}
} else {
if (!value)
throw new Err(
'Value is required for non-function predicate',
'EnmapValueError',
);
const pathValue = _get(parsed, pathOrFn);
if (value === pathValue) {
accumulator.push(parsed);
}
}
return accumulator;
},
result: (accumulator) => JSON.stringify(accumulator),
});
const results = this.#db
.prepare(`SELECT filter(value, key) FROM ${this.#name}`)
.pluck()
.get();
return JSON.parse(results);
}
/**
* Deletes entries that satisfy the provided filter function or value matching.
* @param {Function|string} pathOrFn The path to the value to test against, or the function to test with.
* @param {*} [value] The expected value - only applicable and required if using a property for the first argument.
* @returns {number} The number of removed entries.
*/
sweep(pathOrFn, value) {
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
const deleteStmt = this.#db.prepare(
`DELETE FROM ${this.#name} WHERE key = ?`,
);
const deleteKeys = [];
const deleteMany = this.#db.transaction((cats) => {
for (const cat of cats) deleteStmt.run(cat);
});
let count = 0;
for (const row of stmt.iterate()) {
const parsed = this.#parse(row.value);
const data = isNil(value) ? parsed : _get(parsed, pathOrFn);
if (isFunction(pathOrFn)) {
if (pathOrFn(data, row.key)) {
count++;
deleteKeys.push(row.key);
}
} else {
if (value === data) {
count++;
deleteKeys.push(row.key);
}
}
}
deleteMany(deleteKeys);
return count;
}
/**
* Function called whenever data changes within Enmap after the initial load.
* Can be used to detect if another part of your code changed a value in enmap and react on it.
* @example
* enmap.changed((keyName, oldValue, newValue) => {
* console.log(`Value of ${keyName} has changed from: \n${oldValue}\nto\n${newValue}`);
* });
* @param {Function} cb A callback function that will be called whenever data changes in the enmap.
*/
changed(cb) {
this.#changedCB = cb;
}
/**
* Separates the Enmap into multiple arrays given a function that separates them.
* @param {*} pathOrFn the path to the value to test against, or the function to test with.
* @param {*} value the value to use as a condition for partitioning.
* @returns {Array<Array<*>>} An array of arrays with the partitioned data.
*/
partition(pathOrFn, value) {
const results = [[], []];
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
for (const row of stmt.iterate()) {
const parsed = this.#parse(row.value);
const data = isNil(value) ? parsed : _get(parsed, pathOrFn);
if (isFunction(pathOrFn)) {
if (pathOrFn(data, row.key)) {
results[0].push(parsed);
} else {
results[1].push(parsed);
}
} else {
if (value === data) {
results[0].push(parsed);
} else {
results[1].push(parsed);
}
}
}
return results;
}
// MARK: Internal Methods
/*
* Internal method used to insert or update a key in the database without circular calls to ensure() or others.
* @param {string} key Key to update in database
* @param {*} value value to save in database
* Path is not supported in this method as it writes the whole key.
*/
#set(key, value) {
let serialized;
try {
serialized = stringify(this.#serializer(value, key));
} catch (e) {
serialized = stringify(this.#serializer(onChange.target(value), key));
}
this.#db
.prepare(
`INSERT OR REPLACE INTO ${this.#name} (key, value) VALUES (?, ?)`,
)
.run(key, serialized);
}
/*
* Internal Method. Parses JSON data.
* Reserved for future use (logical checking)
* @param {*} value The data to check/parse
* @returns {*} An object or the original data.
*/
#parse(value) {
let parsed;
try {
parsed = parse(value);
try {
parsed = this.#deserializer(parsed);
} catch (e) {
throw new Err(
'Error while deserializing data: ',
e.message,
'EnmapParseError',
);
}
} catch (e) {
throw new Err(
'Error while deserializing data: ',
e.message,
'EnmapParseError',
);
}
return parsed;
}
#keycheck(key, type = 'key') {
if (typeof key !== 'string') {
throw new Error(
`Invalid ${type} for enmap - keys must be a string.`,
);
}
// I would love to know what went through my brain when I put this in.
// if (!NAME_REGEX.test(key)) {
// throw new Error(
// `Invalid ${type} for enmap - only alphanumeric characters, underscores and hyphens are allowed.`,
// );
// }
}
/*
* INTERNAL method to verify the type of a key or property
* Will THROW AN ERROR on wrong type, to simplify code.
* @param {string} key Required. The key of the element to check
* @param {string} type Required. The javascript constructor to check
* @param {string} path Optional. The dotProp path to the property in the object enmap.
*/
#check(key, type, path) {
key = key.toString();
if (!this.has(key))
throw new Err(
`The key "${key}" does not exist in the enmap "${this.#name}"`,
'EnmapPathError',
);
if (!type) return;
if (!isArray(type)) type = [type];
if (!isNil(path)) {
this.#check(key, 'Object');
const data = this.get(key);
if (isNil(_get(data, path))) {
throw new Err(
`The property "${path}" in key "${key}" does not exist. Please set() it or ensure() it."`,
'EnmapPathError',
);
}
if (!type.includes(_get(data, path).constructor.name)) {
throw new Err(
`The property "${path}" in key "${key}" is not of type "${type.join(
'" or "',
)}" in the enmap "${this.#name}"
(key was of type "${_get(data, path).constructor.name}")`,
'EnmapTypeError',
);
}
} else if (!type.includes(this.get(key).constructor.name)) {
throw new Err(
`The value for key "${key}" is not of type "${type.join(
'" or "',
)}" in the enmap "${this.#name}" (value was of type "${
this.get(key).constructor.name
}")`,
'EnmapTypeError',
);
}
}
/*
* INTERNAL method to execute a mathematical operation. Cuz... javascript.
* And I didn't want to import mathjs!
* @param {number} base the lefthand operand.
* @param {string} op the operation.
* @param {number} opand the righthand operand.
* @return {number} the result.
*/
#math(base, op, opand) {
if (base == undefined || op == undefined || opand == undefined)
throw new Err(
'Math Operation requires base and operation',
'EnmapTypeError',
);
switch (op) {
case 'add':
case 'addition':
case '+':
return base + opand;
case 'sub':
case 'subtract':
case '-':
return base - opand;
case 'mult':
case 'multiply':
case '*':
return base * opand;
case 'div':
case 'divide':
case '/':
return base / opand;
case 'exp':
case 'exponent':
case '^':
return Math.pow(base, opand);
case 'mod':
case 'modulo':
case '%':
return base % opand;
case 'rand':
case 'random':
return Math.floor(Math.random() * Math.floor(opand));
}
return null;
}
}
export default Enmap;