• Добавлена автоматическая детекция типов связей • Реализован Proxy-доступ к связям через свойства • Поддержка каскадного удаления данных • Добавлены транзакции с откатом состояния • Обновлена документация и примеры использования Миграция: - Вместо методов xown()/shared() используйте прямое присваивание - Методы getXown() заменены на доступ через свойства
367 lines
11 KiB
JavaScript
367 lines
11 KiB
JavaScript
import fs from 'fs/promises';
|
|
import { EventEmitter } from 'events';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
class GreenBean extends EventEmitter {
|
|
constructor(dbPath = 'data.json', { autoSave = false } = {}) {
|
|
super();
|
|
this.dbPath = dbPath;
|
|
this.data = null;
|
|
this.initialized = false;
|
|
this.frozen = false;
|
|
this.autoSave = autoSave;
|
|
this._initPromise = null;
|
|
this._transactionLock = false;
|
|
this._relationCache = new WeakMap();
|
|
}
|
|
|
|
async dispense(type, props = {}) {
|
|
await this.init();
|
|
const bean = {
|
|
id: uuidv4(),
|
|
__type: type,
|
|
__meta: {
|
|
created: new Date().toISOString(),
|
|
modified: new Date().toISOString()
|
|
},
|
|
...props
|
|
};
|
|
return this.createProxy(bean);
|
|
}
|
|
|
|
async load(type, id) {
|
|
await this.init();
|
|
const item = this.data[type]?.find(i => i.id === id);
|
|
if (!item) return null;
|
|
return this.createProxy({...item});
|
|
}
|
|
|
|
async store(bean) {
|
|
if (this.frozen) throw new Error('Frozen database');
|
|
await this.init();
|
|
|
|
const type = bean.__type;
|
|
bean.__meta.modified = new Date().toISOString();
|
|
|
|
if (!this.data[type]) this.data[type] = [];
|
|
|
|
const index = this.data[type].findIndex(i => i.id === bean.id);
|
|
if (index >= 0) {
|
|
this.data[type][index] = {...bean};
|
|
} else {
|
|
this.data[type].push({...bean});
|
|
}
|
|
|
|
await this.autoSave && this.save();
|
|
return bean;
|
|
}
|
|
|
|
async trash(bean) {
|
|
if (this.frozen) throw new Error('Frozen database');
|
|
await this.init();
|
|
|
|
const type = bean.__type;
|
|
if (!this.data[type]) return;
|
|
|
|
this.data[type] = this.data[type].filter(i => i.id !== bean.id);
|
|
await this.handleCascadeDelete(bean);
|
|
await this.autoSave && this.save();
|
|
}
|
|
|
|
async find(type, criteria = {}) {
|
|
await this.init();
|
|
if (!this.data[type]) return [];
|
|
|
|
return this.data[type]
|
|
.filter(item => this.matchCriteria(item, criteria))
|
|
.map(item => this.createProxy({...item}));
|
|
}
|
|
|
|
async findOne(type, criteria) {
|
|
const results = await this.find(type, criteria);
|
|
return results[0] || null;
|
|
}
|
|
|
|
async transaction(callback) {
|
|
if (this._transactionLock) throw new Error('Transaction in progress');
|
|
this._transactionLock = true;
|
|
|
|
const snapshot = JSON.stringify(this.data);
|
|
try {
|
|
const result = await callback();
|
|
await this.save();
|
|
return result;
|
|
} catch (error) {
|
|
this.data = JSON.parse(snapshot);
|
|
throw error;
|
|
} finally {
|
|
this._transactionLock = false;
|
|
}
|
|
}
|
|
|
|
createProxy(bean) {
|
|
const handler = {
|
|
get: (target, prop) => {
|
|
if (prop === 'then') return; // Для async/await
|
|
if (prop in target) return target[prop];
|
|
|
|
return new Proxy({}, {
|
|
get: async (_, propName) => {
|
|
const relations = await this.resolveRelations(target);
|
|
return relations[prop];
|
|
},
|
|
apply: async () => {
|
|
return this.handleRelationAccess(target, prop);
|
|
}
|
|
});
|
|
},
|
|
|
|
set: async (target, prop, value) => {
|
|
if (prop === '__type' || prop === '__meta') {
|
|
throw new Error(`Cannot modify ${prop}`);
|
|
}
|
|
|
|
const relationType = await this.detectRelationship(target.__type, prop);
|
|
if (relationType) {
|
|
await this.handleRelationUpdate(target, prop, value, relationType);
|
|
return true;
|
|
}
|
|
|
|
if (prop.endsWith('List') && Array.isArray(value)) {
|
|
target[prop] = value.map(i => ({
|
|
id: i.id,
|
|
__type: i.__type || this.guessType(i)
|
|
}));
|
|
} else {
|
|
target[prop] = value;
|
|
}
|
|
|
|
target.__meta.modified = new Date().toISOString();
|
|
await this.autoSave && this.store(target);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
return new Proxy(bean, handler);
|
|
}
|
|
|
|
// ========== Relationship Handling ==========
|
|
async resolveRelations(target) {
|
|
if (!this._relationCache.has(target)) {
|
|
this._relationCache.set(target, {});
|
|
}
|
|
const cache = this._relationCache.get(target);
|
|
|
|
return new Proxy({}, {
|
|
get: (_, prop) => {
|
|
if (!cache[prop]) {
|
|
cache[prop] = this.handleRelationAccess(target, prop);
|
|
}
|
|
return cache[prop];
|
|
}
|
|
});
|
|
}
|
|
|
|
async handleRelationAccess(target, prop) {
|
|
const relation = await this.detectRelationship(target.__type, prop);
|
|
|
|
switch (relation.type) {
|
|
case 'manyToMany':
|
|
return this.getShared(target, prop);
|
|
case 'oneToMany':
|
|
return this.getOwn(target, prop);
|
|
case 'oneToOne':
|
|
return this.getXOwn(target, prop);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async handleRelationUpdate(target, prop, value, relation) {
|
|
switch (relation.type) {
|
|
case 'manyToMany':
|
|
await this.shared(target, prop, value);
|
|
break;
|
|
case 'oneToMany':
|
|
await this.own(target, prop, value);
|
|
break;
|
|
case 'oneToOne':
|
|
await this.xownOne(target, prop, value);
|
|
break;
|
|
}
|
|
this._relationCache.delete(target);
|
|
}
|
|
|
|
async detectRelationship(parentType, property) {
|
|
// Many-to-Many detection
|
|
const linkTable = `${parentType}_${property}`;
|
|
if (this.data[linkTable]) {
|
|
return { type: 'manyToMany', linkTable };
|
|
}
|
|
|
|
// One-to-Many detection
|
|
const childType = property;
|
|
if (this.data[childType]?.some(i => i[`${parentType}_id`])) {
|
|
return { type: 'oneToMany', childType };
|
|
}
|
|
|
|
// One-to-One detection
|
|
if (this.data[property]?.some(i =>
|
|
i[`${parentType}_id`] && this.isFieldUnique(property, `${parentType}_id`)
|
|
)) {
|
|
return { type: 'oneToOne', childType: property };
|
|
}
|
|
|
|
return { type: 'none' };
|
|
}
|
|
|
|
async getShared(target, prop) {
|
|
const linkTable = `${target.__type}_${prop}`;
|
|
const links = await this.find(linkTable, {
|
|
[`${target.__type}_id`]: target.id
|
|
});
|
|
|
|
return Promise.all(
|
|
links.map(link =>
|
|
this.load(link[`${prop}_type`], link[`${prop}_id`])
|
|
)
|
|
);
|
|
}
|
|
|
|
async getOwn(target, prop) {
|
|
return this.find(prop, {
|
|
[`${target.__type}_id`]: target.id
|
|
});
|
|
}
|
|
|
|
async getXOwn(target, prop) {
|
|
return this.findOne(prop, {
|
|
[`${target.__type}_id`]: target.id
|
|
});
|
|
}
|
|
|
|
async shared(target, prop, values) {
|
|
const linkTable = `${target.__type}_${prop}`;
|
|
await this.trashRelations(linkTable, target.id);
|
|
|
|
for (const value of [values].flat()) {
|
|
const link = await this.dispense(linkTable, {
|
|
[`${target.__type}_id`]: target.id,
|
|
[`${prop}_id`]: value.id,
|
|
[`${prop}_type`]: value.__type
|
|
});
|
|
await this.store(link);
|
|
}
|
|
}
|
|
|
|
async own(target, prop, values) {
|
|
const children = [values].flat();
|
|
for (const child of children) {
|
|
child[`${target.__type}_id`] = target.id;
|
|
await this.store(child);
|
|
}
|
|
}
|
|
|
|
async xownOne(target, prop, value) {
|
|
const current = await this.getXOwn(target, prop);
|
|
if (current) {
|
|
delete current[`${target.__type}_id`];
|
|
await this.store(current);
|
|
}
|
|
if (value) {
|
|
value[`${target.__type}_id`] = target.id;
|
|
await this.store(value);
|
|
}
|
|
}
|
|
|
|
// ========== Helpers ==========
|
|
async init() {
|
|
if (this._initPromise) return this._initPromise;
|
|
this._initPromise = (async () => {
|
|
try {
|
|
const exists = await fs.access(this.dbPath).then(() => true).catch(() => false);
|
|
this.data = exists ?
|
|
JSON.parse(await fs.readFile(this.dbPath, 'utf-8')) :
|
|
{};
|
|
this.initialized = true;
|
|
} catch (error) {
|
|
throw new Error(`Init failed: ${error.message}`);
|
|
}
|
|
})();
|
|
return this._initPromise;
|
|
}
|
|
|
|
async save() {
|
|
await fs.writeFile(this.dbPath, JSON.stringify(this.data, null, 2));
|
|
this.emit('save');
|
|
}
|
|
|
|
async handleCascadeDelete(bean) {
|
|
const relations = await this.detectAllRelationships(bean.__type);
|
|
for (const rel of relations) {
|
|
switch (rel.type) {
|
|
case 'oneToMany':
|
|
const children = await this.find(rel.childType, {
|
|
[`${bean.__type}_id`]: bean.id
|
|
});
|
|
for (const child of children) {
|
|
await this.trash(child);
|
|
}
|
|
break;
|
|
case 'oneToOne':
|
|
const linked = await this.findOne(rel.childType, {
|
|
[`${bean.__type}_id`]: bean.id
|
|
});
|
|
if (linked) await this.trash(linked);
|
|
break;
|
|
case 'manyToMany':
|
|
await this.trashRelations(rel.linkTable, bean.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
async trashRelations(linkTable, sourceId) {
|
|
if (!this.data[linkTable]) return;
|
|
this.data[linkTable] = this.data[linkTable].filter(
|
|
i => i[`${linkTable.split('_')[0]}_id`] !== sourceId
|
|
);
|
|
}
|
|
|
|
matchCriteria(item, criteria) {
|
|
return Object.entries(criteria).every(([key, condition]) => {
|
|
if (typeof condition === 'object' && condition !== null) {
|
|
return Object.entries(condition).every(([op, value]) => {
|
|
switch(op) {
|
|
case 'gt': return item[key] > value;
|
|
case 'lt': return item[key] < value;
|
|
case 'like': return new RegExp(value).test(item[key]);
|
|
default: return item[key] === condition;
|
|
}
|
|
});
|
|
}
|
|
return item[key] === condition;
|
|
});
|
|
}
|
|
|
|
isFieldUnique(type, field) {
|
|
const values = this.data[type]?.map(i => i[field]);
|
|
return new Set(values).size === values?.length;
|
|
}
|
|
|
|
guessType(obj) {
|
|
return obj.__type || Object.getPrototypeOf(obj)?.constructor?.name.toLowerCase();
|
|
}
|
|
|
|
freeze(frozen = true) {
|
|
this.frozen = frozen;
|
|
}
|
|
|
|
async nuke() {
|
|
if (this.frozen) throw new Error('Cannot nuke frozen DB');
|
|
this.data = {};
|
|
await this.save();
|
|
}
|
|
}
|
|
|
|
export default GreenBean; |