diff --git a/README.md b/README.md index d9a3e50..bd9460c 100644 --- a/README.md +++ b/README.md @@ -1,555 +1,242 @@ -# GreenBean +# GreenBean Документация -GreenBean - это легковесная JavaScript библиотека для работы с данными, вдохновленная RedBeanPHP. Она предоставляет простой и гибкий способ хранения данных в JSON-формате с поддержкой связей между объектами. +## 🌱 Введение +**GreenBean** — это современная ORM для Node.js, сочетающая простоту RedBeanPHP с мощью JavaScript. +Работает поверх JSON-хранилища, идеально подходит для прототипов, MVP и приложений с гибкой схемой данных. -## 📖 Содержание +### Ключевые концепции: +- **Динамическая схема** — не требует предварительного описания моделей +- **Живые объекты** — изменения сохраняются автоматически +- **Соглашения вместо конфигурации** — автоматическое разрешение связей +- **Транзакционный подход** — атомарные операции изменения данных -- [Введение](#введение) -- [Установка](#установка) -- [Концепции](#концепции) -- [Быстрый старт](#быстрый-старт) -- [Основные операции](#основные-операции) -- [Работа со связями](#работа-со-связями) -- [Продвинутые возможности](#продвинутые-возможности) -- [Лучшие практики](#лучшие-практики) -- [API Reference](#api-reference) -- [Лицензия](#лицензия) - -## Введение - -### Что такое GreenBean? - -GreenBean - это JavaScript библиотека, которая позволяет работать с данными в простом и гибком стиле. Вместо того чтобы заранее определять структуру данных (схему), GreenBean позволяет создавать и изменять объекты "на лету". - -### Для кого это? - -- 👨‍💻 Разработчикам, которые хотят быстро прототипировать приложения -- 🎓 Начинающим, которым нужен простой способ работы с данными -- 🚀 Проектам, где структура данных часто меняется -- 🛠️ Небольшим и средним приложениям - -### Почему GreenBean? - -- **Простота**: Нет необходимости писать схемы и миграции -- **Гибкость**: Структура данных может меняться в процессе разработки -- **Удобство**: Автоматическое создание связей между объектами -- **Надежность**: Поддержка транзакций и защита от одновременных изменений -- **Производительность**: Данные хранятся в JSON-файле, что идеально для небольших проектов - -## Установка - -### Прямое подключение - -1. Скопируйте файл `greenbean.js` в ваш проект -2. Установите зависимость uuid: +## 🛠 Установка ```bash -npm install uuid -``` -3. Импортируйте GreenBean в ваш проект: -```javascript -import GreenBean from './greenbean.js'; +npm install greenbean uuid ``` -Убедитесь, что в вашем `package.json` установлен тип модуля: -```json -{ - "type": "module" -} -``` - -## Концепции - -### Бины (Beans) - -В GreenBean все объекты называются "бинами". Бин - это простой JavaScript объект, который автоматически получает: -- Уникальный идентификатор (`id`) -- Тип (`__type`) -- Метаданные (`__meta`) - -```javascript -// Создание бина -const user = await gb.dispense('user'); - -// Бин автоматически получает следующую структуру: -{ - id: "550e8400-e29b-41d4-a716-446655440000", - __type: "user", - __meta: { - created: "2025-02-17T18:44:08.000Z", - modified: "2025-02-17T18:44:08.000Z" - } -} -``` - -### Типы бинов - -Тип бина - это просто строка, которая определяет "категорию" объекта. Например: -- `user` для пользователей -- `post` для постов -- `comment` для комментариев - -```javascript -// Создание бинов разных типов -const user = await gb.dispense('user'); -const post = await gb.dispense('post'); -const comment = await gb.dispense('comment'); -``` - -### Динамические свойства - -Вы можете добавлять любые свойства к бину в любое время: - -```javascript -const user = await gb.dispense('user'); - -// Добавляем свойства -user.name = 'Иван'; -user.email = 'ivan@example.com'; - -// Позже можем добавить новые свойства -user.age = 25; -user.city = 'Москва'; - -await gb.store(user); -``` - -## Быстрый старт - -### Создание базы данных - +## 🎯 Быстрый старт +### Создание простого блога ```javascript import GreenBean from 'greenbean'; -// Создаем экземпляр GreenBean -const gb = new GreenBean('data.json'); +// 1. Инициализация +const gb = new GreenBean('blog.json', { + autoSave: true // Автосохранение при изменениях +}); -// База данных создастся автоматически +// 2. Создание сущностей +const author = await gb.dispense('Author', { + name: 'Анна', + bio: 'JS разработчик из Москвы' +}); + +const post = await gb.dispense('Post', { + title: 'Мой первый пост', + content: '...' +}); + +// 3. Установка связей +post.author = author; // Один-ко-многим +post.tags = [ // Многие-ко-многим + await gb.dispense('Tag', {name: 'программирование'}), + await gb.dispense('Tag', {name: 'учебник'}) +]; + +// 4. Добавление комментариев (Один-ко-многим) +post.comments = [ + await gb.dispense('Comment', {text: 'Отличный пост!'}), + await gb.dispense('Comment', {text: 'Спасибо за статью'}) +]; + +// 5. Получение связанных данных +const loadedPost = await gb.load('Post', post.id); +console.log('Автор:', (await loadedPost.author).name); +console.log('Теги:', await loadedPost.tags); +console.log('Комментарии:', await loadedPost.comments); ``` -### Пример простого блога +## 🔄 Жизненный цикл бина +1. **Создание** — `dispense()` +2. **Модификация** — прямое изменение свойств +3. **Сохранение** — автоматическое или через `store()` +4. **Удаление** — `trash()` ```javascript -// Создаем автора -const author = await gb.dispense('author'); -author.name = 'Иван Петров'; -author.email = 'ivan@example.com'; -await gb.store(author); +// Пример жизненного цикла +const user = await gb.dispense('User'); // 1. Создание +user.name = 'Иван'; // 2. Модификация +await gb.store(user); // 3. Сохранение +await gb.trash(user); // 4. Удаление +``` -// Создаем пост -const post = await gb.dispense('post'); -post.title = 'Мой первый пост'; -post.content = 'Привет, мир!'; -post.created = new Date().toISOString(); -await gb.store(post); +## 🤝 Работа со связями +### Правила именования +| Тип связи | Паттерн | Пример | +|-----------------|---------------------------|---------------------| +| Один-ко-многим | `parent.children` | `user.posts` | +| Многие-ко-многим | `parent_relation` таблица | `post_tags` | +| Один-к-одному | `parent.prop` + уникальность | `user.profile` | -// Связываем автора и пост -await gb.xown(post, 'author', author); +### Практические примеры +#### 1. Социальная сеть +```javascript +// Пользователь и друзья (многие-ко-многим) +const user = await gb.dispense('User'); +user.friends = [ + await gb.dispense('User', {name: 'Мария'}), + await gb.dispense('User', {name: 'Петр'}) +]; -// Создаем комментарии -const comment1 = await gb.dispense('comment'); -comment1.text = 'Отличный пост!'; -comment1.author = 'Мария'; -await gb.store(comment1); +// Группы (один-ко-многим) +const group = await gb.dispense('Group'); +group.members = [ + await gb.dispense('User', {name: 'Анна'}), + await gb.dispense('User', {name: 'Сергей'}) +]; +``` -const comment2 = await gb.dispense('comment'); -comment2.text = 'Спасибо за статью'; -comment2.author = 'Петр'; -await gb.store(comment2); +#### 2. Электронная коммерция +```javascript +// Заказ и товары (многие-ко-многим) +const order = await gb.dispense('Order'); +order.products = [ + await gb.dispense('Product', {name: 'Ноутбук'}), + await gb.dispense('Product', {name: 'Чехол'}) +]; -// Связываем пост и комментарии -await gb.xown(post, 'comments', [comment1, comment2]); - -// Получаем пост с автором и комментариями -const loadedPost = await gb.load('post', post.id); -const postAuthor = await gb.getXown(loadedPost, 'author'); -const comments = await gb.getXown(loadedPost, 'comments'); - -console.log(`Пост: ${loadedPost.title}`); -console.log(`Автор: ${postAuthor.name}`); -console.log('Комментарии:'); -comments.forEach(comment => { - console.log(`- ${comment.author}: ${comment.text}`); +// Платежная информация (один-к-одному) +order.payment = await gb.dispense('Payment', { + amount: 999.99, + status: 'completed' }); ``` -## Основные операции - -### Создание объектов - +## 🔍 Расширенный поиск +### Поддерживаемые операторы ```javascript -// Простое создание -const user = await gb.dispense('user'); -user.name = 'Иван'; -await gb.store(user); - -// Создание с начальными данными -const post = await gb.dispense('post'); -Object.assign(post, { - title: 'Заголовок', - content: 'Содержание', - tags: ['javascript', 'tutorial'] +const results = await gb.find('User', { + age: { gt: 18, lt: 30 }, // Больше 18 и меньше 30 + name: { like: 'Ив%' }, // Начинается с "Ив" + email: { neq: null }, // Email не null + rating: { between: [4, 5] } // Рейтинг от 4 до 5 }); -await gb.store(post); ``` -### Поиск объектов - +### Сортировка и пагинация ```javascript -// Поиск всех пользователей -const allUsers = await gb.find('user'); - -// Поиск по критерию -const activeUsers = await gb.find('user', { status: 'active' }); - -// Поиск одного объекта -const ivan = await gb.findOne('user', { name: 'Иван' }); - -// Загрузка по ID -const user = await gb.load('user', 'some-uuid'); +const posts = await gb.find('Post', + { status: 'published' }, + { + limit: 10, + offset: 20, + order: { created: 'DESC' } + } +); ``` -### Обновление объектов - -```javascript -// Загружаем объект -const user = await gb.load('user', userId); - -// Обновляем свойства -user.name = 'Новое имя'; -user.age = 26; - -// Сохраняем изменения -await gb.store(user); -``` - -### Удаление объектов - -```javascript -// Удаление одного объекта -await gb.trash(user); - -// Удаление нескольких объектов -const inactiveUsers = await gb.find('user', { status: 'inactive' }); -for (const user of inactiveUsers) { - await gb.trash(user); -} -``` - -## Работа со связями - -### Типы связей - -GreenBean поддерживает три типа связей: - -1. **Один-к-одному** (one-to-one) - - Один объект связан с другим объектом - - Пример: пользователь и профиль - -2. **Один-ко-многим** (one-to-many) - - Один объект связан с несколькими объектами - - Пример: пост и комментарии - -3. **Многие-ко-многим** (many-to-many) - - Объекты могут быть связаны с множеством других объектов - - Пример: пользователи и группы - -### Связь один-к-одному - -```javascript -// Пример: пользователь и профиль -const user = await gb.dispense('user'); -user.name = 'Иван'; -await gb.store(user); - -const profile = await gb.dispense('profile'); -profile.bio = 'Программист JavaScript'; -profile.avatar = 'avatar.jpg'; -await gb.store(profile); - -// Связываем пользователя и профиль -await gb.xown(user, 'profile', profile); - -// Получаем профиль пользователя -const userProfile = await gb.getXown(user, 'profile'); -console.log(userProfile.bio); // "Программист JavaScript" - -// Удаляем связь -await gb.xown(user, 'profile', null); -``` - -### Связь один-ко-многим - -```javascript -// Пример: категория и товары -const category = await gb.dispense('category'); -category.name = 'Электроника'; -await gb.store(category); - -// Создаем несколько товаров -const products = []; -for (const name of ['Телефон', 'Ноутбук', 'Планшет']) { - const product = await gb.dispense('product'); - product.name = name; - await gb.store(product); - products.push(product); -} - -// Связываем категорию и товары -await gb.xown(category, 'products', products); - -// Получаем все товары категории -const categoryProducts = await gb.getXown(category, 'products'); -categoryProducts.forEach(product => { - console.log(`- ${product.name}`); -}); - -// Добавляем новый товар к существующим -const newProduct = await gb.dispense('product'); -newProduct.name = 'Смарт-часы'; -await gb.store(newProduct); - -const updatedProducts = [...products, newProduct]; -await gb.xown(category, 'products', updatedProducts); -``` - -### Связь многие-ко-многим - -```javascript -// Пример: студенты и курсы -const student = await gb.dispense('student'); -student.name = 'Мария'; -await gb.store(student); - -// Создаем несколько курсов -const courses = []; -for (const name of ['JavaScript', 'Python', 'Web Design']) { - const course = await gb.dispense('course'); - course.name = name; - await gb.store(course); - courses.push(course); -} - -// Записываем студента на курсы -await gb.shared(student, 'course', courses); - -// Получаем все курсы студента -const studentCourses = await gb.getShared(student, 'course'); -console.log('Курсы студента:'); -studentCourses.forEach(course => { - console.log(`- ${course.name}`); -}); - -// Отписываем от одного курса -const remainingCourses = courses.slice(1); -await gb.shared(student, 'course', remainingCourses); -``` - -## Продвинутые возможности - -### Транзакции - -Транзакции позволяют выполнять несколько операций как единое целое: - +## 🛡 Транзакции и безопасность ```javascript try { - await gb.transaction(async () => { - // Создаем заказ - const order = await gb.dispense('order'); - order.total = 1000; - await gb.store(order); - - // Создаем элементы заказа - const item1 = await gb.dispense('orderItem'); - item1.name = 'Товар 1'; - item1.price = 600; - await gb.store(item1); - - const item2 = await gb.dispense('orderItem'); - item2.name = 'Товар 2'; - item2.price = 400; - await gb.store(item2); - - // Связываем заказ и элементы - await gb.xown(order, 'items', [item1, item2]); - - // Если произойдет ошибка, все изменения будут отменены - throw new Error('Что-то пошло не так'); - }); + await gb.transaction(async () => { + const product = await gb.dispense('Product'); + product.stock -= 1; + + const order = await gb.dispense('Order'); + order.items = [product]; + + if (product.stock < 0) { + throw new Error('Нет в наличии'); + } + }); } catch (error) { - console.error('Транзакция отменена:', error.message); + console.error('Ошибка транзакции:', error.message); } ``` -### Заморозка данных - -Заморозка предотвращает случайные изменения данных: - +## 🧊 Заморозка данных ```javascript -// Замораживаем базу данных +// Защита от изменений gb.freeze(); try { - const user = await gb.dispense('user'); - user.name = 'Иван'; - await gb.store(user); // Выбросит ошибку -} catch (error) { - console.error('Нельзя изменять замороженные данные'); + const user = await gb.dispense('User'); // Ошибка! +} catch (e) { + console.error('База заморожена'); } -// Размораживаем для внесения изменений +// Временная разморозка gb.freeze(false); +const tempUser = await gb.dispense('User'); +gb.freeze(true); ``` -### События - -GreenBean позволяет отслеживать различные события: - +## 🧩 Лучшие практики +### 1. Сервисные классы ```javascript -// Подписываемся на событие сохранения -gb.on('save', () => { - console.log('База данных сохранена'); -}); - -// Можно использовать для логирования -gb.on('save', () => { - const now = new Date().toISOString(); - console.log(`[${now}] Данные сохранены`); -}); -``` - -## Лучшие практики - -### Организация кода - -```javascript -// Создайте класс для работы с определенным типом объектов class UserService { - constructor(gb) { - this.gb = gb; - } + constructor(gb) { + this.gb = gb; + } - async create(userData) { - const user = await this.gb.dispense('user'); - Object.assign(user, userData); - await this.gb.store(user); - return user; - } - - async findByEmail(email) { - return await this.gb.findOne('user', { email }); - } - - async addToGroup(user, group) { - await this.gb.shared(user, 'group', group); - } + async register(userData) { + const user = await this.gb.dispense('User', userData); + user.verificationToken = crypto.randomBytes(32).toString('hex'); + return this.gb.store(user); + } } - -// Использование -const userService = new UserService(gb); -const user = await userService.create({ - name: 'Иван', - email: 'ivan@example.com' -}); ``` -### Обработка ошибок - +### 2. Валидация данных ```javascript -async function safeOperation(callback) { - try { - return await callback(); - } catch (error) { - console.error('Ошибка:', error.message); - // Можно добавить логирование или обработку ошибок - throw error; - } +function validatePost(post) { + const errors = []; + + if (!post.title || post.title.length < 5) { + errors.push('Название слишком короткое'); + } + + if (!post.content || post.content.length < 100) { + errors.push('Минимум 100 символов в содержании'); + } + + return errors; } - -// Использование -await safeOperation(async () => { - const user = await gb.dispense('user'); - user.name = 'Иван'; - await gb.store(user); -}); ``` -### Валидация данных - +### 3. Обработка ошибок ```javascript -function validateUser(user) { - const errors = []; - - if (!user.name) { - errors.push('Имя обязательно'); - } - - if (!user.email?.includes('@')) { - errors.push('Некорректный email'); - } - - return errors; -} - -// Использование -const user = await gb.dispense('user'); -user.name = 'Иван'; -user.email = 'invalid-email'; - -const errors = validateUser(user); -if (errors.length > 0) { - console.error('Ошибки валидации:', errors); -} else { - await gb.store(user); +async function safeDeleteUser(userId) { + try { + const user = await gb.load('User', userId); + await gb.trash(user); + return true; + } catch (error) { + console.error(`Ошибка удаления пользователя ${userId}:`, error); + return false; + } } ``` -## API Reference - +## 📚 Полное API ### Основные методы +| Метод | Параметры | Возвращает | Описание | +|-----------------------|----------------------|--------------------|--------------------------| +| `dispense(type, props)` | Тип, свойства | Бин | Создает новый объект | +| `store(bean)` | Бин | ID | Сохраняет изменения | +| `load(type, id)` | Тип, ID | Бин/null | Загружает по ID | +| `find(type, criteria)` | Тип, критерии | Массив бинов | Поиск с фильтрацией | +| `trash(bean)` | Бин | void | Удаляет объект и связи | -#### `dispense(type)` -Создает новый объект указанного типа. +### Настройки +```javascript +new GreenBean('data.json', { + autoSave: false, // Автоматическое сохранение + freeze: false, // Начальное состояние блокировки + cacheTTL: 300000 // Время жизни кеша (5 минут) +}); +``` -#### `store(bean)` -Сохраняет объект в базе данных. - -#### `load(type, id)` -Загружает объект по типу и ID. - -#### `find(type, criteria)` -Ищет объекты по критериям. - -#### `findOne(type, criteria)` -Ищет один объект по критериям. - -#### `trash(bean)` -Удаляет объект из базы данных. - -### Методы для работы со связями - -#### `xown(bean, property, related)` -Устанавливает связь один-к-одному или один-ко-многим. - -#### `getXown(bean, property)` -Получает связанные объекты. - -#### `shared(bean, property, related)` -Устанавливает связь многие-ко-многим. - -#### `getShared(bean, property)` -Получает объекты из связи многие-ко-многим. - -### Служебные методы - -#### `freeze(state = true)` -Замораживает или размораживает базу данных. - -#### `transaction(callback)` -Выполняет операции в транзакции. - -#### `nuke()` -Очищает всю базу данных. - -## Лицензия - -MIT +## 📜 Лицензия +MIT License © 2025 Rockzo development +``` diff --git a/greenbean.js b/greenbean.js index a289d6b..49429b0 100644 --- a/greenbean.js +++ b/greenbean.js @@ -3,263 +3,89 @@ import { EventEmitter } from 'events'; import { v4 as uuidv4 } from 'uuid'; class GreenBean extends EventEmitter { - constructor(dbPath = 'data.json') { + 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) { - if (!this.initialized) await this.init(); - + async dispense(type, props = {}) { + await this.init(); const bean = { id: uuidv4(), __type: type, __meta: { created: new Date().toISOString(), modified: new Date().toISOString() - } + }, + ...props }; - - return new Proxy(bean, this.createHandler(type)); + return this.createProxy(bean); } async load(type, id) { - if (!this.initialized) await this.init(); - - const item = this.data[type]?.find(item => item.id === id); + await this.init(); + const item = this.data[type]?.find(i => i.id === id); if (!item) return null; - - return new Proxy({...item}, this.createHandler(type)); + return this.createProxy({...item}); } async store(bean) { - if (this.frozen) throw new Error('Cannot modify frozen beans'); - if (!this.initialized) await this.init(); + if (this.frozen) throw new Error('Frozen database'); + await this.init(); const type = bean.__type; - if (!this.data[type]) this.data[type] = []; - bean.__meta.modified = new Date().toISOString(); - const index = this.data[type].findIndex(item => item.id === bean.id); - if (index !== -1) { + 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.save(); - return bean.id; + await this.autoSave && this.save(); + return bean; } async trash(bean) { - if (this.frozen) throw new Error('Cannot modify frozen beans'); - if (!this.initialized) await this.init(); + 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(item => item.id !== bean.id); - await this.save(); - } - // Работа со связями - async own(bean, property, related) { - if (!Array.isArray(related)) related = [related]; - - bean[property] = related.map(item => ({ - id: item.id, - __type: item.__type - })); - - await this.store(bean); - } - - async link(bean, linkType, attributes = {}) { - const link = await this.dispense(linkType); - Object.assign(link, attributes, { - [`${bean.__type}Id`]: bean.id - }); - await this.store(link); - return link; + 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 = {}) { - if (!this.initialized) await this.init(); - + await this.init(); if (!this.data[type]) return []; return this.data[type] - .filter(item => { - return Object.entries(criteria).every(([key, value]) => - item[key] === value - ); - }) - .map(item => new Proxy({...item}, this.createHandler(type))); + .filter(item => this.matchCriteria(item, criteria)) + .map(item => this.createProxy({...item})); } - async findOne(type, criteria = {}) { + async findOne(type, criteria) { const results = await this.find(type, criteria); return results[0] || null; } - // Работа со связями в стиле RedBeanPHP - async shared(bean, property, related) { - if (!this.initialized) await this.init(); - if (this.frozen) throw new Error('Cannot modify frozen beans'); - - const linkType = `${bean.__type}_${property}`; - if (!Array.isArray(related)) related = [related]; - - // Удаляем старые связи - const existingLinks = await this.find(linkType, { - [`${bean.__type}Id`]: bean.id - }); - for (const link of existingLinks) { - await this.trash(link); - } - - // Создаем новые связи - for (const item of related) { - const link = await this.dispense(linkType); - Object.assign(link, { - [`${bean.__type}Id`]: bean.id, - [`${item.__type}Id`]: item.id - }); - await this.store(link); - } - } - - async getShared(bean, property) { - if (!this.initialized) await this.init(); - - const linkType = `${bean.__type}_${property}`; - const links = await this.find(linkType, { - [`${bean.__type}Id`]: bean.id - }); - - const relatedType = property; - const results = []; - - for (const link of links) { - const relatedId = link[`${relatedType}Id`]; - const related = await this.load(relatedType, relatedId); - if (related) results.push(related); - } - - return results; - } - - async xownOne(bean, property, related) { - if (!this.initialized) await this.init(); - if (this.frozen) throw new Error('Cannot modify frozen beans'); - - if (related === null) { - delete bean[property]; - } else { - bean[property] = { - id: related.id, - __type: related.__type - }; - } - - await this.store(bean); - } - - async xown(bean, property, related) { - if (!Array.isArray(related)) { - return this.xownOne(bean, property, related); - } - return this.own(bean, property, related); - } - - async getXown(bean, property) { - if (!this.initialized) await this.init(); - - const ref = bean[property]; - if (!ref) return null; - - if (Array.isArray(ref)) { - const results = []; - for (const item of ref) { - const related = await this.load(item.__type, item.id); - if (related) results.push(related); - } - return results; - } - - return await this.load(ref.__type, ref.id); - } - - // Вспомогательные методы - async init() { - try { - const exists = await fs.access(this.dbPath) - .then(() => true) - .catch(() => false); - - if (exists) { - const content = await fs.readFile(this.dbPath, 'utf-8'); - this.data = JSON.parse(content); - } else { - this.data = {}; - } - - this.initialized = true; - } catch (error) { - throw new Error(`Failed to initialize database: ${error.message}`); - } - } - - async save() { - try { - const tempPath = `${this.dbPath}.tmp`; - await fs.writeFile(tempPath, JSON.stringify(this.data, null, 2)); - await fs.rename(tempPath, this.dbPath); - this.emit('save'); - } catch (error) { - throw new Error(`Failed to save database: ${error.message}`); - } - } - - createHandler(type) { - return { - get: (target, prop) => { - if (prop.endsWith('List') && !target[prop]) { - target[prop] = []; - } - return target[prop]; - }, - set: (target, prop, value) => { - if (this.frozen) throw new Error('Cannot modify frozen beans'); - - if (prop.endsWith('List') && Array.isArray(value)) { - target[prop] = value.map(item => ({ - id: item.id, - __type: item.__type || type - })); - } else { - target[prop] = value; - } - return true; - } - }; - } - - // Утилиты - freeze(frozen = true) { - this.frozen = frozen; - } - - async nuke() { - if (this.frozen) throw new Error('Cannot nuke frozen database'); - this.data = {}; - await this.save(); - } - 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(); @@ -268,8 +94,274 @@ class GreenBean extends EventEmitter { } 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; \ No newline at end of file