334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
import { randomBytes } from 'node:crypto';
|
|
import { getProperty } from 'dot-prop';
|
|
import inflection from 'inflection';
|
|
import sortOn from 'sort-on';
|
|
export function isItem(obj) {
|
|
return typeof obj === 'object' && obj !== null;
|
|
}
|
|
export function isData(obj) {
|
|
if (typeof obj !== 'object' || obj === null) {
|
|
return false;
|
|
}
|
|
const data = obj;
|
|
return Object.values(data).every((value) => Array.isArray(value) && value.every(isItem));
|
|
}
|
|
var Condition;
|
|
(function (Condition) {
|
|
Condition["lt"] = "lt";
|
|
Condition["lte"] = "lte";
|
|
Condition["gt"] = "gt";
|
|
Condition["gte"] = "gte";
|
|
Condition["ne"] = "ne";
|
|
Condition["default"] = "";
|
|
})(Condition || (Condition = {}));
|
|
function isCondition(value) {
|
|
return Object.values(Condition).includes(value);
|
|
}
|
|
function ensureArray(arg = []) {
|
|
return Array.isArray(arg) ? arg : [arg];
|
|
}
|
|
function embed(db, name, item, related) {
|
|
if (inflection.singularize(related) === related) {
|
|
const relatedData = db.data[inflection.pluralize(related)];
|
|
if (!relatedData) {
|
|
return item;
|
|
}
|
|
const foreignKey = `${related}Id`;
|
|
const relatedItem = relatedData.find((relatedItem) => {
|
|
return relatedItem['id'] === item[foreignKey];
|
|
});
|
|
return { ...item, [related]: relatedItem };
|
|
}
|
|
const relatedData = db.data[related];
|
|
if (!relatedData) {
|
|
return item;
|
|
}
|
|
const foreignKey = `${inflection.singularize(name)}Id`;
|
|
const relatedItems = relatedData.filter((relatedItem) => relatedItem[foreignKey] === item['id']);
|
|
return { ...item, [related]: relatedItems };
|
|
}
|
|
function nullifyForeignKey(db, name, id) {
|
|
const foreignKey = `${inflection.singularize(name)}Id`;
|
|
Object.entries(db.data).forEach(([key, items]) => {
|
|
// Skip
|
|
if (key === name)
|
|
return;
|
|
// Nullify
|
|
if (Array.isArray(items)) {
|
|
items.forEach((item) => {
|
|
if (item[foreignKey] === id) {
|
|
item[foreignKey] = null;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
function deleteDependents(db, name, dependents) {
|
|
const foreignKey = `${inflection.singularize(name)}Id`;
|
|
Object.entries(db.data).forEach(([key, items]) => {
|
|
// Skip
|
|
if (key === name || !dependents.includes(key))
|
|
return;
|
|
// Delete if foreign key is null
|
|
if (Array.isArray(items)) {
|
|
db.data[key] = items.filter((item) => item[foreignKey] !== null);
|
|
}
|
|
});
|
|
}
|
|
function randomId() {
|
|
return randomBytes(2).toString('hex');
|
|
}
|
|
function fixItemsIds(items) {
|
|
items.forEach((item) => {
|
|
if (typeof item['id'] === 'number') {
|
|
item['id'] = item['id'].toString();
|
|
}
|
|
if (item['id'] === undefined) {
|
|
item['id'] = randomId();
|
|
}
|
|
});
|
|
}
|
|
// Ensure all items have an id
|
|
function fixAllItemsIds(data) {
|
|
Object.values(data).forEach((value) => {
|
|
if (Array.isArray(value)) {
|
|
fixItemsIds(value);
|
|
}
|
|
});
|
|
}
|
|
export class Service {
|
|
#db;
|
|
constructor(db) {
|
|
fixAllItemsIds(db.data);
|
|
this.#db = db;
|
|
}
|
|
#get(name) {
|
|
return this.#db.data[name];
|
|
}
|
|
has(name) {
|
|
return Object.prototype.hasOwnProperty.call(this.#db?.data, name);
|
|
}
|
|
findById(name, id, query) {
|
|
const value = this.#get(name);
|
|
if (Array.isArray(value)) {
|
|
let item = value.find((item) => item['id'] === id);
|
|
ensureArray(query._embed).forEach((related) => {
|
|
if (item !== undefined)
|
|
item = embed(this.#db, name, item, related);
|
|
});
|
|
return item;
|
|
}
|
|
return;
|
|
}
|
|
find(name, query = {}) {
|
|
let items = this.#get(name);
|
|
if (!Array.isArray(items)) {
|
|
return items;
|
|
}
|
|
// Include
|
|
ensureArray(query._embed).forEach((related) => {
|
|
if (items !== undefined && Array.isArray(items)) {
|
|
items = items.map((item) => embed(this.#db, name, item, related));
|
|
}
|
|
});
|
|
// Return list if no query params
|
|
if (Object.keys(query).length === 0) {
|
|
return items;
|
|
}
|
|
// Convert query params to conditions
|
|
const conds = [];
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (value === undefined || typeof value !== 'string') {
|
|
continue;
|
|
}
|
|
const re = /_(lt|lte|gt|gte|ne)$/;
|
|
const reArr = re.exec(key);
|
|
const op = reArr?.at(1);
|
|
if (op && isCondition(op)) {
|
|
const field = key.replace(re, '');
|
|
conds.push([field, op, value]);
|
|
continue;
|
|
}
|
|
if ([
|
|
'_embed',
|
|
'_sort',
|
|
'_start',
|
|
'_end',
|
|
'_limit',
|
|
'_page',
|
|
'_per_page',
|
|
].includes(key)) {
|
|
continue;
|
|
}
|
|
conds.push([key, Condition.default, value]);
|
|
}
|
|
// Loop through conditions and filter items
|
|
let filtered = items;
|
|
for (const [key, op, paramValue] of conds) {
|
|
filtered = filtered.filter((item) => {
|
|
if (paramValue && !Array.isArray(paramValue)) {
|
|
// https://github.com/sindresorhus/dot-prop/issues/95
|
|
const itemValue = getProperty(item, key);
|
|
switch (op) {
|
|
// item_gt=value
|
|
case Condition.gt: {
|
|
if (!(typeof itemValue === 'number' &&
|
|
itemValue > parseInt(paramValue))) {
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
// item_gte=value
|
|
case Condition.gte: {
|
|
if (!(typeof itemValue === 'number' &&
|
|
itemValue >= parseInt(paramValue))) {
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
// item_lt=value
|
|
case Condition.lt: {
|
|
if (!(typeof itemValue === 'number' &&
|
|
itemValue < parseInt(paramValue))) {
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
// item_lte=value
|
|
case Condition.lte: {
|
|
if (!(typeof itemValue === 'number' &&
|
|
itemValue <= parseInt(paramValue))) {
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
// item_ne=value
|
|
case Condition.ne: {
|
|
switch (typeof itemValue) {
|
|
case 'number':
|
|
return itemValue !== parseInt(paramValue);
|
|
case 'string':
|
|
return itemValue !== paramValue;
|
|
case 'boolean':
|
|
return itemValue !== (paramValue === 'true');
|
|
}
|
|
break;
|
|
}
|
|
// item=value
|
|
case Condition.default: {
|
|
switch (typeof itemValue) {
|
|
case 'number':
|
|
return itemValue === parseInt(paramValue);
|
|
case 'string':
|
|
return itemValue === paramValue;
|
|
case 'boolean':
|
|
return itemValue === (paramValue === 'true');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
// Sort
|
|
const sort = query._sort || '';
|
|
const sorted = sortOn(filtered, sort.split(','));
|
|
// Slice
|
|
const start = query._start;
|
|
const end = query._end;
|
|
const limit = query._limit;
|
|
if (start !== undefined) {
|
|
if (end !== undefined) {
|
|
return sorted.slice(start, end);
|
|
}
|
|
return sorted.slice(start, start + (limit || 0));
|
|
}
|
|
if (limit !== undefined) {
|
|
return sorted.slice(0, limit);
|
|
}
|
|
// Paginate
|
|
let page = query._page;
|
|
const perPage = query._per_page || 10;
|
|
if (page) {
|
|
const items = sorted.length;
|
|
const pages = Math.ceil(items / perPage);
|
|
// Ensure page is within the valid range
|
|
page = Math.max(1, Math.min(page, pages));
|
|
const first = 1;
|
|
const prev = page > 1 ? page - 1 : null;
|
|
const next = page < pages ? page + 1 : null;
|
|
const last = pages;
|
|
const start = (page - 1) * perPage;
|
|
const end = start + perPage;
|
|
const data = sorted.slice(start, end);
|
|
return {
|
|
first,
|
|
prev,
|
|
next,
|
|
last,
|
|
pages,
|
|
items,
|
|
data,
|
|
};
|
|
}
|
|
return sorted.slice(start, end);
|
|
}
|
|
async create(name, data = {}) {
|
|
const items = this.#get(name);
|
|
if (items === undefined || !Array.isArray(items))
|
|
return;
|
|
const item = { id: randomId(), ...data };
|
|
items.push(item);
|
|
await this.#db.write();
|
|
return item;
|
|
}
|
|
async #updateOrPatch(name, body = {}, isPatch) {
|
|
const item = this.#get(name);
|
|
if (item === undefined || Array.isArray(item))
|
|
return;
|
|
const nextItem = (this.#db.data[name] = isPatch ? { item, ...body } : body);
|
|
await this.#db.write();
|
|
return nextItem;
|
|
}
|
|
async #updateOrPatchById(name, id, body = {}, isPatch) {
|
|
const items = this.#get(name);
|
|
if (items === undefined || !Array.isArray(items))
|
|
return;
|
|
const item = items.find((item) => item['id'] === id);
|
|
if (!item)
|
|
return;
|
|
const nextItem = isPatch ? { ...item, ...body, id } : { ...body, id };
|
|
const index = items.indexOf(item);
|
|
items.splice(index, 1, nextItem);
|
|
await this.#db.write();
|
|
return nextItem;
|
|
}
|
|
async update(name, body = {}) {
|
|
return this.#updateOrPatch(name, body, false);
|
|
}
|
|
async patch(name, body = {}) {
|
|
return this.#updateOrPatch(name, body, true);
|
|
}
|
|
async updateById(name, id, body = {}) {
|
|
return this.#updateOrPatchById(name, id, body, false);
|
|
}
|
|
async patchById(name, id, body = {}) {
|
|
return this.#updateOrPatchById(name, id, body, true);
|
|
}
|
|
async destroyById(name, id, dependent) {
|
|
const items = this.#get(name);
|
|
if (items === undefined || !Array.isArray(items))
|
|
return;
|
|
const item = items.find((item) => item['id'] === id);
|
|
if (item === undefined)
|
|
return;
|
|
const index = items.indexOf(item);
|
|
items.splice(index, 1);
|
|
nullifyForeignKey(this.#db, name, id);
|
|
const dependents = ensureArray(dependent);
|
|
deleteDependents(this.#db, name, dependents);
|
|
await this.#db.write();
|
|
return item;
|
|
}
|
|
}
|