UNPKG

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
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