## @e22m4u/js-repository Абстракция для работы с базами данных для Node.js ## Установка ```bash npm install @e22m4u/js-repository ``` Опционально устанавливаем адаптер. Например, если используется *MongoDB*, то для подключения потребуется установить [адаптер mongodb](https://www.npmjs.com/package/@e22m4u/js-repository-mongodb-adapter) как отдельную зависимость. ```bash npm install @e22m4u/js-repository-mongodb-adapter ``` **Список доступных адаптеров:** | адаптер | описание | |-----------|-------------------------------------------------------------------------------------------------------------------------------------------------| | `memory` | виртуальная база в памяти процесса (для разработки и тестирования) | | `mongodb` | MongoDB - система управления NoSQL базами (*[требует установки](https://www.npmjs.com/package/@e22m4u/js-repository-mongodb-adapter))* | ## Концепция Модуль позволяет спроектировать систему связанных данных, доступ к которым осуществляется посредством репозиториев. Каждый репозиторий имеет собственную модель данных, которая описывает структуру определенной коллекции в базе, а так же определяет связи к другим коллекциям. ```mermaid flowchart LR A[Datasource]-->B[Data Model]-->С[Repository]; ``` ## Использование Определения источников и моделей хранятся в экземпляре класса `Schema`, и первым шагом будет создание данного экземпляра. ```js import {Schema} from '@e22m4u/js-repository'; const schema = new Schema(); ``` Интерфейс `Schema` содержит три основных метода: - `defineDatasource(datasourceDef: object): this` - добавить источник - `defineModel(modelDef: object): this` - добавить модель - `getRepository(modelName: string): Repository` - получить репозиторий ### Источник данных Источник описывает способ подключения к базе и используемый адаптер. Если адаптер имеет настройки, то они передаются вместе с объектом определения источника методом `defineDatasource`, как это показано ниже. ```js schema.defineDatasource({ name: 'myMongo', // название нового источника adapter: 'mongodb', // название выбранного адаптера // настройки адаптера mongodb host: '127.0.0.1', port: 27017, database: 'data' }); ``` **Параметры источника:** - `name: string` уникальное название - `adapter: string` выбранный адаптер При желании можно использовать встроенный адаптер `memory`, который хранит данные в памяти процесса. У него нет специальных настроек, и он отлично подходит для тестов и прототипирования. ```js schema.defineDatasource({ name: 'myMemory', // название источника adapter: 'memory', // выбранный адаптер }); ``` ### Модель данных Когда источники определены, можно перейти к описанию моделей данных. Модель может определять как структуру какого-либо объекта, так и являться отражением реальной коллекции базы. Представьте себе коллекцию торговых точек, у каждой из которых имеются координаты `lat` и `lng`. Мы можем заранее определить модель для объекта координат методом `defineModel` и использовать ее в других коллекциях. ```js schema.defineModel({ name: 'latLng', // название новой модели properties: { // поля модели lat: DataType.NUMBER, // поле широты lng: DataType.NUMBER, // поле долготы }, }); ``` **Параметры модели:** - `name: string` уникальное название (обязательно) - `datasource: string` выбранный источник данных - `properties: object` определения полей модели - `relations: object` определения связанных коллекций Параметр `properties` принимает объект, ключи которого являются именами полей, а значением тип поля или объект с дополнительными параметрами. ```js schema.defineModel({ name: 'latLng', properties: { lat: DataType.NUMBER, // краткое определение поля "lat" lng: { // расширенное определение поля "lng" type: DataType.NUMBER, // тип допустимого значения required: true, // исключает null и undefined }, }, }); ``` **Типы данных:** - `DataType.ANY` - `DataType.STRING` - `DataType.NUMBER` - `DataType.BOOLEAN` - `DataType.ARRAY` - `DataType.OBJECT` Модель `latLng` всего лишь описывает структуру объекта координат, тогда как торговая точка должна иметь реальную таблицу в базе. По аналогии с предыдущим примером, добавим модель `place`, но дополнительно укажем источник данных в параметре `datasource` ```js schema.defineModel({ name: 'place', datasource: 'myMemory', // выбранный источник данных properties: { name: DataType.STRING, // поле для названия торговой точки location: { // поле для объекта координат type: DataType.OBJECT, // допускать только объекты model: 'latLng', // определение модели объекта координат }, }, }); ``` В примере выше мы использовали модель `latLng` как структуру допустимого значения поля `location`. Возможный документ данной коллекции может выглядеть так: ```json { "id": 1, "name": "Burger King", "location": { "lat": 32.412891, "lng": 34.7660061 } } ``` Стоит обратить внимание, что мы могли бы не объявлять параметр `properties`, при этом теряя возможность проверки данных перед записью в базу. ```js schema.defineModel({ name: 'place', adapter: 'myMemory', // параметр "properties" не указан }); ``` **Параметры поля:** - `type: string` тип допустимого значения (обязательно) - `itemType: string` тип элемента массива (для `type: 'array'`) - `model: string` модель объекта (для `type: 'object'`) - `primaryKey: boolean` объявить поле первичным ключом - `columnName: string` переопределение названия колонки - `columnType: string` тип колонки (определяется адаптером) - `required: boolean` объявить поле обязательным - `default: any` значение по умолчанию ### Репозиторий В отличие от `latLng`, модель `place` имеет источник данных с названием `myMemory`, который был объявлен ранее. Наличие источника позволяет получить репозиторий по названию модели. ```js const rep = schema.getRepository('place'); ``` **Методы:** - `create(data, filter = undefined)` - `replaceById(id, data, filter = undefined)` - `patchById(id, data, filter = undefined)` - `patch(data, where = undefined)` - `find(filter = undefined)` - `findOne(filter = undefined)` - `findById(id, filter = undefined)` - `delete(where = undefined)` - `deleteById(id)` - `exists(id)` - `count(where = undefined)` #### create(data, filter = undefined) Добавляет новый документ в коллекцию и возвращает его. ```js // вызов метода `create` с передачей состава // нового документа первым параметром const result = await rep.create({ name: 'Rick Sanchez', dimension: 'C-137', age: 67, }); // вывод результата console.log(result); // { // id: 1, <= определено базой данных // name: 'Rick Sanchez', // dimension: 'C-137', // age: 67 // } ``` Использование второго параметра `filter` метода `create` позволяет ограничивать количество возвращаемых данных, и разрешать связи к другим коллекциям (см. Связи). ```js // подготовка состава для нового документа const data = { name: 'Rick Sanchez', dimension: 'C-137', age: 67, biographyId: 59, // связь "biography" (см. belongsTo) pictureIds: [345, 346], // связь "pictures" (см. referencesMany) } // подготовка параметра "filter" const filter = { // "fields" - если определено, то результат // будут включать только указанные поля fields: [ 'name', 'biographyId' 'pictureIds', ], // "include" - включение в результат // связанных документов (см. Связи) include: [ 'biography', 'pictures', ], } // вызов метода `create` и вывод результата const result = await rep.create(data, filter); console.log(result); // { // "name": "Rick Sanchez", // "biographyId": 59, // "biography": { <= разрешение связи // "id": 59, // "annotation": "This article is about Rick Sanchez", // "body": "He is a genius scientist whose ..." // } // "pictureIds": [345, 346], // "pictures": [ <= разрешение связи // { // "id": 345, // "mime": "image/jpeg", // "file": "/uploads/rick345.jpg" // }, // { // "id": 346, // "mime": "image/jpeg", // "file": "/uploads/rick346.jpg" // } // ], // } // // поля "age" и "dimension" // исключены опцией "fields" // // документы "biography" и "pictures" // встроены опцией "include" (см. Связи) ``` #### replaceById(id, data, filter = undefined) Заменяет существующий документ по идентификатору и возвращает его. Если идентификатор не найден, то выбрасывает исключение. ```js // документ с идентификатором 12 // имеет следующую структуру // { // id: 12, // name: 'Rick Sanchez', // dimension: 'C-137', // age: 67 // } // вызов метода `replaceById` с передачей // идентификатора и нового состава const result = await rep.replaceById(12, { name: 'Morty Smith', kind: 'a young teenage boy', age: 14, }); // вывод результата console.log(result); // { // id: 12, // name: 'Morty Smith', <= обновлено // kind: 'a young teenage boy', <= новое поле // age: 14 <= обновлено // } // // поле "dimension" удалено, так как // не передавалось с новым составом ``` Использование третьего параметра `filter` метода `replaceById` позволяет ограничивать количество возвращаемых данных, и разрешать связи к другим коллекциям (см. Связи). ```js // подготовка состава для заменяемого документа const data = { name: 'Morty Smith', kind: 'a young teenage boy', age: 14, biographyId: 61, // связь "biography" (см. belongsTo) pictureIds: [347, 348], // связь "pictures" (см. referencesMany) } // подготовка параметра "filter" const filter = { // "fields" - если определено, то результат // будут включать только указанные поля fields: [ 'name', 'biographyId' 'pictureIds', ], // "include" - включение в результат // связанных документов (см. Связи) include: [ 'biography', 'pictures', ], } // вызов метода `replaceById` и вывод результата const result = await rep.replaceById(12, data, filter); console.log(result); // { // "name": "Morty Smith", // "biographyId": 61, <= новое поле // "biography": { <= разрешение связи // "id": 61, // "annotation": "This article is about Morty Smith", // "body": "Currently, Morty is 14 years old ..." // }, // "pictureIds": [347, 348], <= новое поле // "picture": [ <= разрешение связи // { // "id": 347, // "mime": "image/jpeg", // "file": "/uploads/morty347.jpg" // }, // { // "id": 348, // "mime": "image/jpeg", // "file": "/uploads/morty348.jpg" // } // ] // } // // поля "kind" и "age" записаны в документ, // но исключены из результата опцией "fields" // // документы "biography" и "pictures" // встроены опцией "include" (см. Связи) ``` #### patchById(id, data, filter = undefined) Частично обновляет существующий документ по идентификатору и возвращает его. Если идентификатор не найден, то выбрасывает исключение. ```js // документ с идентификатором 24 // имеет следующую структуру // { // "id": 24, // "type": "airport", // "name": "Domodedovo Airport", // "code": "DME" // } // вызов метода `patchById` с передачей // идентификатора и новых значений // обновляемых полей const result = await rep.patchById(24, { name: 'Sheremetyevo Airport', code: 'SVO', featured: true, }); // вывод результата console.log(result); // { // "id": 24, // "type": "airport", // "name": "Sheremetyevo Airport", <= обновлено // "code": "SVO", <= обновлено // "featured": true <= новое поле // } ``` Использование третьего параметра `filter` метода `patchById` позволяет ограничивать количество возвращаемых данных, и разрешать связи к другим коллекциям (см. Связи). ```js // подготовка обновляемых данных const data = { name: 'Sheremetyevo Airport', code: 'SVO', featured: true, cityId: 231, // связь "city" (см. belongsTo) companyIds: [513, 514], // связь "companies" (см. referencesMany) } // подготовка параметра `filter` const filter = { // "fields" - если определено, то результат // будут включать только указанные поля fields: [ 'name', 'cityId', 'companyIds', ], // "include" - включение в результат // связанных документов (см. Связи) include: [ 'city', 'companies', ], } // вызов метода `patchById` и вывод результата const result = await rep.patchById(24, data, filter); console.log(result); // { // "id": 24, // "name": "Sheremetyevo Airport", // "cityId": 231, <= новое поле // "city": { <= разрешение связи // "id": 231, // "name": "Moscow" // }, // "companyIds": [513, 514], <= новое поле // "companies": [ <= разрешение связи // { // "id": 513, // "name": "S7 Airlines" // }, // { // "id": 514, // "name": "Aeroflot Airlines" // }, // ] // } // // поля "code" и "featured" обновлены, // но исключены из ответа опцией "fields" // // документы "city" и "companies" // встроены опцией "include" (см. Связи) ``` #### patch(data, where = undefined) Обновляет документы и возвращает их число. Используется для обновления нескольких документов согласно условиям выборки, или всей коллекции сразу. ```js // коллекция имеет три документа // [ // { // "id": 1, // "type": "city", // "name": "Bangkok", // "hidden": true // }, // { // "id": 2, // "type": "country", // "name": "Thailand", // "hidden": true // }, // { // "id": 3, // "type": "city", // "name": "Moscow", // "hidden": true // } // ] // вызов метода `patch` с передачей // значений для обновляемых полей const result = await rep.patch({ hidden: false, updatedAt: new Date().toISOString(), }); // вывод количество затронутых документов console.log(result); // 3 // просмотр коллекции методом `find` // для проверки изменений const docs = await rep.find(); console.log(docs); // [ // { // "id": 1, // "type": "city", // "name": "Bangkok", // "hidden": false, <= обновлено // "updatedAt": "2023-12-02T14:00:00.000Z" <= новое поле // }, // { // "id": 2, // "type": "country", // "name": "Thailand", // "hidden": false, <= обновлено // "updatedAt": "2023-12-02T14:00:00.000Z" <= новое поле // }, // { // "id": 3, // "type": "city", // "name": "Moscow", // "hidden": false, <= обновлено // "updatedAt": "2023-12-02T14:00:00.000Z" <= новое поле // } // ] ``` Использование второго параметра `where` метода `patch` позволяет определить условия выборки обновляемых документов. ```js // вызов метода `patch` с передачей // условий выборки вторым параметром const result = await rep.patch( // новые значения полей { hidden: false, updatedAt: new Date().toISOString(), }, // условия выборки { type: 'city', // только если "type" равен "city" hidden: true, // и "hidden" имеет значение true } ); // вывод количества затронутых документов console.log(result); // 2 // по условиям выборки обновлено // только 2 документа из 3-х ``` #### find(filter = undefined) Возвращает все документы коллекции или согласно условию. ```js // вызов метода `find` без аргументов // возвращает все документы коллекции const result = await rep.find(); console.log(result); // [ // { // "id": 1, // "type": "article", // "title": "The Forgotten Ship", // "publishedAt": "2023-12-02T08:00:00.000Z", // "featured": true // }, // { // "id": 2, // "type": "article", // "title": "A Giant Bellows", // "publishedAt": "2023-12-02T12:00:00.000Z", // "featured": false // }, // { // "id": 3, // "type": "letter", // "title": "Hundreds of bottles", // "publishedAt": null, // "featured": false // } // ] ``` Фильтрация результата (опционально). ```js // первый параметр может принимать // объект cо следующими настройками const result = await rep.find({ // "where" - фильтрация выборки по условию, где // указанные поля должны содержать определенные // значения (см. Фильтрация) where: {type: 'article', featured: true}, where: {title: {like: 'the forgotten'}}, where: {publishedAt: {lte: '2023-12-02T21:00:00.000Z'}}, // "order" - сортировка по указанному полю может // принимать постфикс DESC для обратного порядка order: 'featured', order: 'publishedAt DESC', order: ['publishedAt DESC', 'featured ASC', 'id'], // "limit" - ограничение результата // "skip" - пропуск документов limit: 10, skip: 10, // "fields" - если определено, то результат // будет включать только указанные поля fields: 'title', fields: ['title', 'featured'], // "include" - включить в результат связанные // документы (см. Связи) include: 'author', include: {author: 'role'}, include: ['author', 'categories'], }); ``` #### findOne(filter = undefined) Возвращает первый найденный документ или `undefined` ```js // коллекция имеет три документа // [ // { // "id": 1, // "type": "article", // "title": "The Forgotten Ship", // "publishedAt": "2023-12-02T08:00:00.000Z", // "featured": true // }, // { // "id": 2, // "type": "article", // "title": "A Giant Bellows", // "publishedAt": "2023-12-02T12:00:00.000Z", // "featured": false // }, // { // "id": 3, // "type": "letter", // "title": "Hundreds of bottles", // "publishedAt": null, // "featured": false // } // ] // вызов метода `findOne` без аргументов // возвращает первый документ коллекции const result = await rep.findOne(); console.log(result); // { // "id": 1, // "type": "article", // "title": "The Forgotten Ship", // "publishedAt": "2023-12-02T08:00:00.000Z", // "featured": true // } ``` Фильтрация результата (опционально). ```js // первый параметр метода `find` принимает // объект настроек возвращаемого результата const result = await rep.findOne({ // "where" - фильтрация выборки по условию, где // указанные поля должны содержать определенные // значения (см. Фильтрация) where: {type: 'article', featured: true}, where: {title: {like: 'the forgotten'}}, where: {publishedAt: {lte: '2023-12-02T21:00:00.000Z'}}, // "order" - сортировка по указанному полю может // принимать постфикс DESC для обратного порядка order: 'featured', order: 'publishedAt DESC', order: ['publishedAt DESC', 'featured ASC', 'id'], // "skip" - пропуск документов skip: 10, // "fields" - если определено, то результат // будет включать только указанные поля fields: 'title', fields: ['title', 'featured'], // "include" - включить в результат связанные // документы (см. Связи) include: 'author', include: {author: 'role'}, include: ['author', 'categories'], }); ``` #### findById(id, filter = undefined) Поиск документа по идентификатору. Возвращает найденный документ или выбрасывает исключение. ```js // коллекция содержит три документа // [ // { // "id": 1, // "title": "The Forgotten Ship", // "featured": true // }, // { // "id": 2, // "title": "A Giant Bellows", // "featured": false // }, // { // "id": 3, // "title": "Hundreds of bottles", // "featured": false // } // ] // вызов метода `findById` с передачей // идентификатора искомого документа const result = await rep.findById(2); console.log(result); // { // "id": 2, // "title": "A Giant Bellows", // "featured": false // } ``` Использование параметра `filter` (опционально). ```js // второй параметр метода `findById` принимает // объект настроек возвращаемого результата const result = await rep.findById(2, { // "fields" - если определено, то результат // будут включать только указанные поля fields: 'title', fields: ['title', 'featured'], // "include" - включить в результат связанные // документы (см. Связи) include: 'author', include: {author: 'role'}, include: ['author', 'categories'], }); ``` #### delete(where = undefined) Удаляет все документы коллекции или согласно условию. Возвращает количество удаленных документов. ```js // коллекция имеет три документа // [ // { // "id": 1, // "title": "The Forgotten Ship", // "featured": true // }, // { // "id": 2, // "title": "A Giant Bellows", // "featured": false // }, // { // "id": 3, // "title": "Hundreds of bottles", // "featured": false // } // ] // вызов метода `delete` без аргументов // удалит все содержимое коллекции const result = await rep.delete(); console.log(result); // 3 // просмотр коллекции методом `find` // для проверки изменений const docs = await rep.find(); console.log(docs); // [] ``` Условия выборки (опционально). ```js // первый параметр метода `delete` // принимает условия выборки const result = await rep.delete({ title: { like: 'bellows', // оператор "like" проверяет поле "title" }, // на содержание подстроки "bellows" featured: false, // значение поля "featured" должно быть false // см. Фильтрация }); // вывод результата console.log(result); // 1 ``` #### deleteById(id) Удаляет документ по идентификатору. Возвращает `true` в случае успеха или `false` если не найден. ```js // коллекция имеет три документа // [ // { // "id": 1, // "title": "The Forgotten Ship" // }, // { // "id": 2, // "title": "A Giant Bellows" // }, // { // "id": 3, // "title": "Hundreds of bottles" // } // ] // вызов метода `deleteById` с передачей // идентификатора удаляемого документа const result = await rep.deleteById(2); // вывод результата console.log(result); // true // просмотр коллекции методом `find` // для проверки изменений const docs = await rep.find(); console.log(docs); // [ // { // "id": 1, // "title": "The Forgotten Ship" // }, // { // "id": 3, // "title": "Hundreds of bottles" // } // ] ``` #### exists(id) Проверка существования документа по идентификатору. Возвращает `true` если найден, в противном случае `false`. ```js // коллекция имеет три документа // [ // { // "id": 1, // "title": "The Forgotten Ship" // }, // { // "id": 2, // "title": "A Giant Bellows" // }, // { // "id": 3, // "title": "Hundreds of bottles" // } // ] // вызов метода `exists` с передачей // существующего идентификатора const result1 = await rep.exists(2); console.log(result1); // true // результат проверки несуществующего // идентификатора const result2 = await rep.exists(10); console.log(result2); // false ``` #### count(where = undefined) Подсчет количества документов и возврат их числа. ```js // коллекция имеет три документа // [ // { // "id": 1, // "title": "The Forgotten Ship", // "featured": true // }, // { // "id": 2, // "title": "A Giant Bellows", // "featured": false // }, // { // "id": 3, // "title": "Hundreds of bottles", // "featured": false // } // ] // вызов метода `count` без аргументов // возвращает общее число документов const result = await rep.count(); console.log(result); // 3 ``` Условия выборки (опционально). ```js // первый параметр метода `count` // принимает условия выборки const result = await rep.count({ featured: { // оператор "neq" проверяет поле "featured" neq: true, // на неравенство значению true } // см. Фильтрация }); // вывод результата console.log(result); // 2 ``` ## Расширение При использовании метода `getRepository` выполняется проверка на наличие существующего экземпляра репозитория для нужной модели. При первичном запросе создается новый экземпляр, который будет сохранен для повторных обращений к методу. ```js import {Schema} from '@e22m4u/js-repository'; import {Repository} from '@e22m4u/js-repository'; // const schema = new Schema(); // schema.defineDatasource ... // schema.defineModel ... const rep1 = schema.getRepository('myModel'); const rep2 = schema.getRepository('myModel'); console.log(rep1 === rep2); // true ``` Если требуется изменить или расширить поведение репозитория, то перед его созданием можно переопределить его конструктор методом `setRepositoryCtor`. После чего, все создаваемые репозитории будут использовать уже пользовательский конструктор вместо стандартного. ```js import {Schema} from '@e22m4u/js-repository'; import {Repository} from '@e22m4u/js-repository'; import {RepositoryRegistry} from '@e22m4u/js-repository'; class MyRepository extends Repository { /*...*/ } const schema = new Schema(); schema.get(RepositoryRegistry).setRepositoryCtor(MyRepository); // теперь schema.getRepository(modelName) будет возвращать // экземпляр класса MyRepository ``` ## Тесты ```bash npm run test ``` ## Лицензия MIT