enmap
Version:
A simple database wrapper to make sqlite database interactions much easier for beginners, with additional array helper methods.
622 lines • 21.9 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.
*/
export default class Enmap {
#name;
#db;
#inMemory;
#autoEnsure;
#ensureProps;
#serializer;
#deserializer;
#changedCB;
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, key) => 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 || 'defaultEnmap';
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();
});
}
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);
return this;
}
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, key) : null;
if (isNil(parsed))
return null;
if (path) {
this.#check(key, ['Object']);
return _get(parsed, path);
}
return parsed;
}
has(key) {
this.#keycheck(key);
const data = this.#db
.prepare(`SELECT count(*) FROM ${this.#name} WHERE key = ?`)
.get(key);
return data['count(*)'] > 0;
}
delete(key, path) {
this.#keycheck(key);
if (path) {
this.#check(key, ['Object']);
const data = this.get(key);
if (data && typeof data === 'object') {
_set(data, path, undefined);
this.set(key, data);
}
}
else {
this.#db.prepare(`DELETE FROM ${this.#name} WHERE key = ?`).run(key);
}
return this;
}
clear() {
this.#db.prepare(`DELETE FROM ${this.#name}`).run();
}
// Getters with proper typing
get size() {
const data = this.#db
.prepare(`SELECT count(*) FROM '${this.#name}';`)
.get();
return data['count(*)'];
}
get count() {
return this.size;
}
get length() {
return this.size;
}
get db() {
return this.#db;
}
get autonum() {
let result = this.#db
.prepare("SELECT lastnum FROM 'internal::autonum' WHERE enmap = ?")
.get(this.#name);
let lastnum = result ? parseInt(result.lastnum.toString(), 10) : 0;
lastnum++;
this.#db
.prepare("INSERT OR REPLACE INTO 'internal::autonum' (enmap, lastnum) VALUES (?, ?)")
.run(this.#name, lastnum);
return lastnum.toString();
}
// Array methods
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();
}
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;
}
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, row.key)]);
}
return entries;
}
update(key, valueOrFunction) {
this.#keycheck(key);
this.#check(key, ['Object']);
const data = this.get(key);
const fn = isFunction(valueOrFunction)
? valueOrFunction
: (currentData) => merge(currentData, valueOrFunction);
const merged = fn(data);
this.#set(key, merged);
return merged;
}
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;
}
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 this;
data.push(value);
this.set(key, data, path);
return this;
}
math(key, operation, operand, path) {
this.#keycheck(key);
this.#check(key, ['Number'], path);
const data = this.get(key, path);
if (typeof data !== 'number') {
throw new Err(`Value at key "${key}" is not a number`, 'EnmapTypeError');
}
const updatedValue = this.#math(data, operation, operand);
this.set(key, updatedValue, path);
return updatedValue;
}
inc(key, path) {
this.#keycheck(key);
this.#check(key, ['Number'], path);
const data = this.get(key, path);
this.set(key, (data + 1), path);
return this;
}
dec(key, path) {
this.#keycheck(key);
this.#check(key, ['Number'], path);
const data = this.get(key, path);
this.set(key, (data - 1), path);
return this;
}
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) && this.get(key, path) !== undefined)
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;
}
includes(key, value, path) {
this.#keycheck(key);
this.#check(key, ['Array'], path);
const data = this.get(key, path);
return data?.includes(value) || false;
}
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) ?? -1;
if (index > -1) {
data.splice(index, 1);
}
this.set(key, data, path);
return this;
}
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(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);
}
return this;
}
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;
}
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, row.key)]);
}
return results;
}
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;
}
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, row.key);
const data = isNil(path) ? parsed : _get(parsed, path);
if (isFunction(valueOrFunction)) {
if (!valueOrFunction(parsed, row.key)) {
return false;
}
}
else {
if (valueOrFunction !== data) {
return false;
}
}
}
return true;
}
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, row.key);
const data = isNil(path) ? parsed : _get(parsed, path);
if (isFunction(valueOrFunction)) {
if (valueOrFunction(parsed, row.key)) {
return true;
}
}
else {
if (valueOrFunction === data) {
return true;
}
}
}
return false;
}
map(pathOrFn) {
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, row.key);
if (isFunction(pathOrFn)) {
results.push(pathOrFn(parsed, row.key));
}
else {
results.push(_get(parsed, pathOrFn));
}
}
return results;
}
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, row.key);
const func = isFunction(pathOrFn)
? pathOrFn
: (v) => value === _get(v, pathOrFn);
if (func(parsed, row.key)) {
return parsed;
}
}
return null;
}
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, row.key);
const func = isFunction(pathOrFn)
? pathOrFn
: (v) => value === _get(v, pathOrFn);
if (func(parsed, row.key)) {
return row.key;
}
}
return null;
}
reduce(predicate, initialValue) {
let accumulator = initialValue;
const stmt = this.#db.prepare(`SELECT key, value FROM ${this.#name}`);
for (const row of stmt.iterate()) {
const parsed = this.#parse(row.value, row.key);
accumulator = predicate(accumulator, parsed, row.key);
}
return accumulator;
}
filter(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, row.key);
if (isFunction(pathOrFn)) {
if (pathOrFn(parsed, row.key)) {
results.push(parsed);
}
}
else {
if (!value)
throw new Err('Value is required for non-function predicate', 'EnmapValueError');
const pathValue = _get(parsed, pathOrFn);
if (value === pathValue) {
results.push(parsed);
}
}
}
return results;
}
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((keys) => {
for (const key of keys)
deleteStmt.run(key);
});
let count = 0;
for (const row of stmt.iterate()) {
const parsed = this.#parse(row.value, row.key);
if (isFunction(pathOrFn)) {
if (pathOrFn(parsed, row.key)) {
count++;
deleteKeys.push(row.key);
}
}
else {
const data = _get(parsed, pathOrFn);
if (value === data) {
count++;
deleteKeys.push(row.key);
}
}
}
deleteMany(deleteKeys);
return count;
}
changed(cb) {
this.#changedCB = cb;
}
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, row.key);
if (isFunction(pathOrFn)) {
if (pathOrFn(parsed, row.key)) {
results[0].push(parsed);
}
else {
results[1].push(parsed);
}
}
else {
const data = _get(parsed, pathOrFn);
if (value === data) {
results[0].push(parsed);
}
else {
results[1].push(parsed);
}
}
}
return results;
}
// MARK: Internal Methods
#set(key, value) {
let serialized;
try {
serialized = stringify(this.#serializer(value, key));
}
catch (e) {
// If serialization fails, try to get the underlying value from onChange proxy
const targetValue = onChange.target && typeof onChange.target === 'function'
? onChange.target(value)
: value;
serialized = stringify(this.#serializer(targetValue, key));
}
this.#db
.prepare(`INSERT OR REPLACE INTO ${this.#name} (key, value) VALUES (?, ?)`)
.run(key, serialized);
}
#parse(value, key) {
let parsed;
try {
parsed = parse(value);
try {
return this.#deserializer(parsed, key || '');
}
catch (e) {
throw new Err('Error while deserializing data: ' + e.message, 'EnmapParseError');
}
}
catch (e) {
throw new Err('Error while deserializing data: ' + e.message, 'EnmapParseError');
}
}
#keycheck(key, type = 'key') {
if (typeof key !== 'string') {
throw new Error(`Invalid ${type} for enmap - keys must be a string.`);
}
}
#check(key, type, path) {
const keyStr = key.toString();
if (!this.has(key))
throw new Err(`The key "${keyStr}" does not exist in the enmap "${this.#name}"`, 'EnmapPathError');
if (!type)
return;
const types = isArray(type) ? type : [type];
if (!isNil(path)) {
this.#check(key, 'Object');
const data = this.get(key);
const pathValue = _get(data, path);
if (isNil(pathValue)) {
throw new Err(`The property "${path}" in key "${keyStr}" does not exist. Please set() it or ensure() it."`, 'EnmapPathError');
}
const constructorName = pathValue?.constructor?.name || 'Unknown';
if (!types.includes(constructorName)) {
throw new Err(`The property "${path}" in key "${keyStr}" is not of type "${types.join('" or "')}" in the enmap "${this.#name}"
(key was of type "${constructorName}")`, 'EnmapTypeError');
}
}
else {
const value = this.get(key);
if (value !== null && value !== undefined) {
const constructorName = value?.constructor?.name || 'Unknown';
if (!types.includes(constructorName)) {
throw new Err(`The value for key "${keyStr}" is not of type "${types.join('" or "')}" in the enmap "${this.#name}" (value was of type "${constructorName}")`, 'EnmapTypeError');
}
}
}
}
#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;
}
}
//# sourceMappingURL=index.js.map