GreenBean/greenbean.js
Vufer be9be042b8 feat(core): Реализация автоматических связей через соглашения
• Добавлена автоматическая детекция типов связей
• Реализован Proxy-доступ к связям через свойства
• Поддержка каскадного удаления данных
• Добавлены транзакции с откатом состояния
• Обновлена документация и примеры использования

Миграция:
- Вместо методов xown()/shared() используйте прямое присваивание
- Методы getXown() заменены на доступ через свойства
2025-02-17 23:23:00 +03:00

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;