Реализация репозитория для работы с базами данных

e22m4u ae50496474 chore: bumps version to 0.8.2 4 weeks ago
.husky 77f2fb02f4 chore: adds CommonJS support 1 year ago
assets eda5f8e43f docs: removes logo and english doc 2 months ago
dist 653db01aa7 refactor: improve linting 1 month ago
src aa684c98e9 chore: updates dependencies 1 month ago
.c8rc dbff18d833 chore: initial commit 2 years ago
.commitlintrc dbff18d833 chore: initial commit 2 years ago
.editorconfig dbff18d833 chore: initial commit 2 years ago
.gitignore 829eb6f924 chore: updates .gitignore 1 year ago
.mocharc.json c6553393b9 refactor: replaces chai-spies with @e22m4u/js-spy 1 month ago
.prettierrc dbff18d833 chore: initial commit 2 years ago
LICENSE 51b0f047e4 chore: updates license 5 months ago
README.md 96f1b14644 refactor: removes 0, [] and {} from empty values 4 weeks ago
build-cjs.js 818dd30ef0 chore: updates esbuild config 1 year ago
eslint.config.js 653db01aa7 refactor: improve linting 1 month ago
mocha.setup.js c6553393b9 refactor: replaces chai-spies with @e22m4u/js-spy 1 month ago
package.json 02035a04b9 chore: bumps version to 0.8.2 4 weeks ago
tsconfig.json 653db01aa7 refactor: improve linting 1 month ago

README.md

@e22m4u/js-repository

npm version license

Реализация паттерна «Репозиторий» для работы с базами данных.

Содержание

Установка

npm install @e22m4u/js-repository

Опционально устанавливается нужный адаптер.

адаптер описание установка
memory Виртуальная база в памяти процесса встроенный
mongodb MongoDB - документо-ориентированная база данных npm

Импорт

Модуль поддерживает ESM и CommonJS стандарты.

ESM

import {DatabaseSchema} from '@e22m4u/js-repository';

CommonJS

const {DatabaseSchema} = require('@e22m4u/js-repository');

Описание

Модуль позволяет абстрагироваться от различных интерфейсов баз данных, представляя их как именованные источники данных, подключаемые к моделям. Модель же описывает таблицу базы, колонки которой являются свойствами модели. Свойства модели могут иметь определенный тип допустимого значения. Кроме того, модель может определять классические связи «один к одному», «один ко многим» и другие типы отношений между моделями.

Непосредственно чтение и запись данных производится с помощью репозитория, который есть у каждой модели с объявленным источником данных. Репозиторий может фильтровать запрашиваемые документы, выполнять валидацию свойств согласно определению модели, и встраивать связанные данные в результат выборки.

  • Источник данных - определяет способ подключения к базе
  • Модель - описывает структуру документа и связи к другим моделям
  • Репозиторий - выполняет операции чтения и записи документов модели
flowchart TD

  A[Схема]
  subgraph Базы данных
    B[Источник данных 1]
    C[Источник данных 2]
  end
  A-->B
  A-->C

  subgraph Коллекции
    D[Модель A]
    E[Модель Б]
    F[Модель В]
    G[Модель Г]
  end
  B-->D
  B-->E
  C-->F
  C-->G

  H[Репозиторий A]
  I[Репозиторий Б]
  J[Репозиторий В]
  K[Репозиторий Г]
  D-->H
  E-->I
  F-->J
  G-->K

Пример

Пример демонстрирует создание экземпляра схемы, объявление источника данных и модели country. После чего, с помощью репозитория данной модели, в коллекцию добавляется новый документ (страна), который выводится в консоль.

   Страна (country)
┌─────────────────────────┐
│  id: 1                  │
│  name: "Russia"         │
│  population: 143400000  │
└─────────────────────────┘
import {DataType} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';

// создание экземпляра DatabaseSchema
const dbs = new DatabaseSchema();

// объявление источника "myDb"
dbs.defineDatasource({
  name: 'myDb',      // название нового источника
  adapter: 'memory', // выбранный адаптер
});

// объявление модели "country"
dbs.defineModel({
  name: 'country',    // название новой модели
  datasource: 'myDb', // выбранный источник
  properties: {       // свойства модели
    name: DataType.STRING,       // тип "string"
    population: DataType.NUMBER, // тип "number"
  },
})

// получение репозитория модели
const countryRep = dbs.getRepository('country');

// добавление нового документа в коллекцию
const country = await countryRep.create({
  name: 'Russia',
  population: 143400000,
});

// вывод нового документа
console.log(country);
// {
//   id: 1,
//   name: 'Russia',
//   population: 143400000,
// }

В следующем блоке определяется модель city со связью belongsTo к модели country из примера выше. Затем создается новый документ города, связанный с ранее созданной страной. После создания нового документа, выполняется запрос на извлечение данного города с включением связанной страны.

   Страна (country)                  Город (city)
┌─────────────────────────┐       ┌─────────────────────────┐
│  id: 1  <───────────────│───┐   │  id: 1                  │
│  name: "Russia"         │   │   │  name: "Moscow"         │
│  population: 143400000  │   └───│─ countryId: 1           │
└─────────────────────────┘       └─────────────────────────┘
// объявление модели "city" со связью к "country"
dbs.defineModel({
  name: 'city',
  datasource: 'myDb',
  properties: {
    name: DataType.STRING,
    countryId: DataType.NUMBER,
    // внешний ключ "countryId" указывать не обязательно,
    // но для проверки типа значения перед записью в базу
    // рекомендуется, так как адаптер "memory" по умолчанию
    // создает числовые идентификаторы
  },
  relations: {
    // определение связи "country" позволит автоматически включать
    // связанные документы с помощью опции "include" при запросах
    // из данной коллекции через методы репозитория
    country: {
      type: RelationType.BELONGS_TO, // тип связи: принадлежит к...
      model: 'country',              // название целевой модели
      foreignKey: 'countryId',       // поле с внешним ключом (не обязательно)
      // если внешний ключ соответствует `relationName` + `Id`,
      // то указывать опцию `foreignKey` не обязательно
    },
  },
});

// получение репозитория для модели "city"
const cityRep = dbs.getRepository('city');

// создание нового города и его привязка к стране через country.id
const city = await cityRep.create({
  name: 'Moscow',
  countryId: country.id, // использование id созданной ранее страны
});

console.log(city);
// {
//   id: 1,
//   name: 'Moscow',
//   countryId: 1,
// }

// извлечение города по идентификатору с включением связанной страны
const cityWithCountry = await cityRep.findById(city.id, {
  include: 'country',
});

console.log(cityWithCountry);
// {
//   id: 1,
//   name: 'Moscow',
//   countryId: 1,
//   country: {
//     id: 1,
//     name: 'Russia',
//     population: 143400000
//   }
// }

Схема

Экземпляр класса DatabaseSchema хранит определения источников данных и моделей.

Методы

  • defineDatasource(datasourceDef: object): this - добавить источник
  • defineModel(modelDef: object): this - добавить модель
  • getRepository(modelName: string): Repository - получить репозиторий

Примеры

Импорт класса и создание экземпляра схемы.

import {DatabaseSchema} from '@e22m4u/js-repository';

const dbs = new DatabaseSchema();

Определение нового источника.

dbs.defineDatasource({
  name: 'myDb', // название нового источника
  adapter: 'memory', // выбранный адаптер
});

Определение новой модели.

dbs.defineModel({
  name: 'product', // название новой модели
  datasource: 'myDb', // выбранный источник
  properties: { // свойства модели
    name: DataType.STRING,
    weight: DataType.NUMBER,
  },
});

Получение репозитория по названию модели.

const productRep = dbs.getRepository('product');

Источник данных

Источник хранит название выбранного адаптера и его настройки. Определение нового источника выполняется методом defineDatasource экземпляра DatabaseSchema.

Параметры

  • name: string уникальное название
  • adapter: string выбранный адаптер
  • параметры адаптера (если имеются)

Примеры

Определение нового источника.

dbs.defineDatasource({
  name: 'myDb', // название нового источника
  adapter: 'memory', // выбранный адаптер
});

Передача дополнительных параметров на примере MongoDB адаптера (установка).

dbs.defineDatasource({
  name: 'myDb',
  adapter: 'mongodb',
  // параметры адаптера "mongodb"
  host: '127.0.0.1',
  port: 27017,
  database: 'myDatabase',
});

Модель

Описывает структуру документа коллекции и связи к другим моделям. Определение новой модели выполняется методом defineModel экземпляра DatabaseSchema.

Параметры

  • name: string название модели (обязательно)
  • base: string название наследуемой модели
  • tableName: string название коллекции в базе
  • datasource: string выбранный источник данных
  • properties: object определения свойств (см. Свойства)
  • relations: object определения связей (см. Связи)

Примеры

Определение модели со свойствами указанного типа.

dbs.defineModel({
  name: 'user', // название новой модели
  properties: { // свойства модели
    name: DataType.STRING,
    age: DataType.NUMBER,
  },
});

Свойства

Параметр properties находится в определении модели и принимает объект, ключи которого являются свойствами этой модели, а значением тип свойства или объект с дополнительными параметрами.

Тип данных

  • DataType.ANY разрешено любое значение
  • DataType.STRING только значение типа string
  • DataType.NUMBER только значение типа number
  • DataType.BOOLEAN только значение типа boolean
  • DataType.ARRAY только значение типа array
  • DataType.OBJECT только значение типа object

Параметры

  • type: string тип допустимого значения (обязательно)
  • itemType: string тип элемента массива (для type: 'array')
  • model: string модель объекта (для type: 'object')
  • primaryKey: boolean объявить свойство первичным ключом
  • columnName: string переопределение названия колонки
  • columnType: string тип колонки (определяется адаптером)
  • required: boolean объявить свойство обязательным
  • default: any значение по умолчанию
  • unique: boolean | string проверять значение на уникальность

Параметр unique

Если значением параметра unique является true или 'strict', то выполняется строгая проверка на уникальность. В этом режиме пустые значения так же подлежат проверке, где null и undefined также считаются значениями, которые должны быть уникальными.

Режим 'sparse' проверяет только значения с полезной нагрузкой, исключая пустые значения, список которых отличается в зависимости от типа свойства. Например, для типа string пустым значением будет undefined, null и '' (пустая строка).

  • unique: true | 'strict' строгая проверка на уникальность
  • unique: 'sparse' исключить из проверки пустые значения
  • unique: false | 'nonUnique' не проверять на уникальность (по умолчанию)

В качестве значений параметра unique можно использовать предопределенные константы как эквивалент строковых значений strict, sparse и nonUnique.

  • PropertyUniqueness.STRICT
  • PropertyUniqueness.SPARSE
  • PropertyUniqueness.NON_UNIQUE

Примеры

Краткое определение свойств модели.

dbs.defineModel({
  name: 'city',
  properties: { // свойства модели
    name: DataType.STRING, // тип свойства "string"
    population: DataType.NUMBER, // тип свойства "number"
  },
});

Расширенное определение свойств модели.

dbs.defineModel({
  name: 'city',
  properties: { // свойства модели
    name: {
      type: DataType.STRING, // тип свойства "string" (обязательно)
      required: true, // исключение значений undefined и null
    },
    population: {
      type: DataType.NUMBER, // тип свойства "number" (обязательно)
      default: 0, // значение по умолчанию
    },
    code: {
      type: DataType.NUMBER, // тип свойства "number" (обязательно)
      unique: PropertyUniqueness.STRICT, // проверять уникальность
    },
  },
});

Фабричное значение по умолчанию. Возвращаемое значение функции будет определено в момент записи документа.

dbs.defineModel({
  name: 'article',
  properties: { // свойства модели
    tags: {
      type: DataType.ARRAY, // тип свойства "array" (обязательно)
      itemType: DataType.STRING, // тип элемента "string"
      default: () => [], // фабричное значение
    },
    createdAt: {
      type: DataType.STRING, // тип свойства "string" (обязательно)
      default: () => new Date().toISOString(), // фабричное значение
    },
  },
});

Пустые значения

Разные типы свойств имеют свои наборы пустых значений. Эти наборы используются для определения наличия полезной нагрузки в значении свойства. Например, параметр default в определении свойства устанавливает значение по умолчанию, только если входящее значение является пустым. Параметр required исключает пустые значения выбрасывая ошибку. А параметр unique в режиме sparse наоборот допускает дублирование пустых значений уникального свойства, поскольку они не участвуют в проверке.

тип пустые значения
'any' undefined, null, ''
'string' undefined, null, ''
'number' undefined, null
'boolean' undefined, null
'array' undefined, null
'object' undefined, null

Переопределение пустых значений

Набор пустых значений для любого типа данных можно переопределить. Управление этими наборами осуществляется через специальный сервис, который предоставляет модуль @e22m4u/js-empty-values (не требует установки).

EmptyValuesService

Для переопределения пустых значений необходимо получить экземпляр класса EmptyValuesService из контейнера схемы и вызвать метод, который принимает тип данных и массив новых значений.

Интерфейс:

class EmptyValuesService {
  /**
   * Установить пустые значения
   * для определенного типа данных.
   * 
   * @param dataType    Тип данных.
   * @param emptyValues Массив новых пустых значений.
   */
  setEmptyValuesOf(
    dataType: DataType,
    emptyValues: unknown[],
  ): this;
}

Пример

Если строковое свойство является обязательным, то значение "" (пустая строка) приведет к ошибке. Следующий пример демонстрирует, как изменить данное поведение, оставив в качестве пустых значений только undefined и null.

import {DataType} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';
import {EmptyValuesService} from '@e22m4u/js-empty-values';

const dbs = new DatabaseSchema();

// получение сервиса для работы с пустыми значениями
const emptyValuesService = dbs.getService(EmptyValuesService);

// переопределение пустых значений для типа DataType.STRING
emptyValuesService.setEmptyValuesOf(DataType.STRING, [undefined, null]);

Теперь пустая строка будет успешно проходить проверку для свойств с типом string, а также не будет заменяться на значение по умолчанию.

Репозиторий

Репозиторий выполняет операции чтения и записи данных определенной модели. Он выступает в роли посредника между бизнес-логикой приложения и базой данных.

Методы

Аргументы

  • id: number|string идентификатор (первичный ключ)
  • data: object данные документа (используется при записи)
  • where: object условия фильтрации (см. Фильтрация)
  • filter: object параметры выборки (см. Фильтрация)

Получение репозитория

Получить репозиторий можно с помощью метода getRepository() экземпляра DatabaseSchema. В качестве аргумента метод принимает название модели. Обязательным условием является наличие у модели определенного источника данных (datasource), так как репозиторий напрямую взаимодействует с базой данных через указанный в источнике адаптер.

// объявление источника
dbs.defineDatasource({
  name: 'myDatasource',
  adapter: 'memory', // адаптер
});

// объявление модели
dbs.defineModel({
  name: 'myModel',
  datasource: 'myDatasource',
  // properties: { ... },
  // relations: { ... }
});

// получение репозитория модели
const modelRep = dbs.getRepository('myModel');

При первом вызове getRepository('myModel') будет создан и сохранен новый экземпляр репозитория. Все последующие вызовы с тем же названием модели будут возвращать уже существующий экземпляр.

repository.create

Создает новый документ в коллекции на основе переданных данных. Возвращает созданный документ с присвоенным идентификатором.

Сигнатура:

create(
  data: WithOptionalId<FlatData, IdName>,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Примеры

Создание нового документа.

const newProduct = await productRep.create({
  name: 'Laptop',
  price: 1200,
});
console.log(newProduct);
// {
//   id: 1,
//   name: 'Laptop',
//   price: 1200,
// }

Создание документа с возвратом определенных полей.

const product = await productRep.create(
  {name: 'Mouse', price: 25},
  {fields: ['id', 'name']},
);
console.log(product);
// {
//   id: 2,
//   name: 'Mouse',
// }

Создание документа с включением связанных данных в результат.

// предполагается, что модель Product имеет связь "category"
// (опция "include" влияет только на возвращаемый результат)
const product = await productRep.create(
  {name: 'Keyboard', price: 75, categoryId: 10},
  {include: 'category'},
);
console.log(product);
// {
//   id: 3,
//   name: 'Keyboard',
//   price: 75,
//   categoryId: 10,
//   category: {id: 10, name: 'Electronics'}
// }

repository.replaceById

Полностью заменяет существующий документ по его идентификатору. Все предыдущие данные документа, кроме идентификатора, удаляются. Поля, которые не были переданы в data, будут отсутствовать в итоговом документе (если для них не задано значение по умолчанию).

Сигнатура:

replaceById(
  id: IdType,
  data: WithoutId<FlatData, IdName>,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Примеры

Замена документа по идентификатору.

// исходный документ
// {
//   id: 1,
//   name: 'Laptop',
//   price: 1200,
//   inStock: true
// }

const updatedProduct = await productRep.replaceById(1, {
  name: 'Laptop Pro',
  price: 1500,
});
console.log(updatedProduct);
// {
//   id: 1,
//   name: 'Laptop Pro',
//   price: 1500
// }
// свойство "inStock" удалено

repository.replaceOrCreate

Заменяет существующий документ, если в переданных данных присутствует идентификатор, который уже существует в коллекции. В противном случае, если идентификатор не указан или не найден, создает новый документ.

Сигнатура:

replaceOrCreate(
  data: WithOptionalId<FlatData, IdName>,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Примеры

Создание нового документа, если id: 3 не существует.

const product = await productRep.replaceOrCreate({
  id: 3,
  name: 'Keyboard',
  price: 75,
});
console.log(product);
// {
//   id: 3,
//   name: 'Keyboard',
//   price: 75,
// }

Замена существующего документа с id: 1.

const updatedProduct = await productRep.replaceOrCreate({
  id: 1,
  name: 'Laptop Pro',
  price: 1500,
});
console.log(updatedProduct);
// {
//   id: 1,
//   name: 'Laptop Pro',
//   price: 1500,
// }

repository.patchById

Частично обновляет существующий документ по его идентификатору, изменяя только переданные поля. Остальные поля документа остаются без изменений.

Сигнатура:

patchById(
  id: IdType,
  data: PartialWithoutId<FlatData, IdName>,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Примеры

Частичное обновление документа по идентификатору.

// исходный документ с id: 1
// {
//   id: 1,
//   name: 'Laptop Pro',
//   price: 1500
// }

const updatedProduct = await productRep.patchById(1, {
  price: 1450,
});
console.log(updatedProduct);
// {
//   id: 1,
//   name: 'Laptop Pro',
//   price: 1450
// }

repository.patch

Частично обновляет один или несколько документов, соответствующих условиям where. Изменяются только переданные поля, остальные остаются без изменений. Возвращает количество обновленных документов. Если where не указан, обновляет все документы в коллекции.

Сигнатура:

patch(
  data: PartialWithoutId<FlatData, IdName>,
  where?: WhereClause<FlatData>,
): Promise<number>;

Примеры

Обновление документов по условию.

// обновит все товары с ценой меньше 30
const updatedCount = await productRep.patch(
  {inStock: false},
  {price: {lt: 30}},
);

Обновление всех документов.

// добавит или обновит поле updatedAt для всех документов
const totalCount = await productRep.patch({
  updatedAt: new Date(),
});

repository.find

Находит все документы, соответствующие условиям фильтрации, и возвращает их в виде массива. Если фильтр не указан, возвращает все документы коллекции.

Сигнатура:

find(filter?: FilterClause<FlatData>): Promise<FlatData[]>;

Примеры

Поиск всех документов.

const allProducts = await productRep.find();

Поиск документов по условию where.

const cheapProducts = await productRep.find({
  where: {price: {lt: 100}},
});

Поиск с сортировкой и ограничением выборки.

const latestProducts = await productRep.find({
  order: 'createdAt DESC',
  limit: 10,
});

repository.findOne

Находит первый документ, соответствующий условиям фильтрации. Возвращает undefined, если документы не найдены.

Сигнатура:

findOne(
  filter?: FilterClause<FlatData>,
): Promise<FlatData | undefined>;

Примеры

Поиск одного документа по условию.

const expensiveProduct = await productRep.findOne({
  where: {price: {gt: 1000}},
  order: 'price DESC',
});

Обработка случая, когда документ не найден.

const product = await productRep.findOne({
  where: {name: 'Non-existent Product'},
});
if (!product) {
  console.log('Product not found.');
}

repository.findById

Находит один документ по его уникальному идентификатору. Если документ не найден, выбрасывается ошибка.

Сигнатура:

findById(
  id: IdType,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Примеры

Поиск документа по id.

try {
  const product = await productRep.findById(1);
  console.log(product);
} catch (error) {
  console.error('Product with id 1 is not found.');
}

Поиск документа с включением связанных данных.

const product = await productRep.findById(1, {
  include: 'category',
});

repository.delete

Удаляет один или несколько документов, соответствующих условиям where. Возвращает количество удаленных документов. Если where не указан, удаляет все документы в коллекции.

Сигнатура:

delete(where?: WhereClause<FlatData>): Promise<number>;

Примеры

Удаление документов по условию.

const deletedCount = await productRep.delete({
  inStock: false,
});

Удаление всех документов.

const totalCount = await productRep.delete();

repository.deleteById

Удаляет один документ по его уникальному идентификатору. Возвращает true, если документ был найден и удален, в противном случае false.

Сигнатура:

deleteById(id: IdType): Promise<boolean>;

Примеры

Удаление документа по id.

const wasDeleted = await productRep.deleteById(1);
if (wasDeleted) {
  console.log('The document was deleted.');
} else {
  console.log('No document found to delete.');
}

repository.exists

Проверяет существование документа с указанным идентификатором. Возвращает true, если документ существует, иначе false.

Сигнатура:

exists(id: IdType): Promise<boolean>;

Примеры

Проверка существования документа по id.

const productExists = await productRep.exists(1);
if (productExists) {
  console.log('A document with id 1 exists.');
}

repository.count

Подсчитывает количество документов, соответствующих условиям where. Если where не указан, возвращает общее количество документов в коллекции.

Сигнатура:

count(where?: WhereClause<FlatData>): Promise<number>;

Примеры

Подсчет документов по условию.

const cheapCount = await productRep.count({
  price: {lt: 100},
});

Подсчет всех документов.

const totalCount = await productRep.count();

Фильтрация

Некоторые методы репозитория принимают объект настроек, влияющий на возвращаемый результат. Максимально широкий набор таких настроек имеет первый параметр метода find, где ожидается объект содержащий набор опций указанных ниже.

  • where: object условия фильтрации по свойствам документа;
  • order: string|string[] сортировка по указанным свойствам;
  • limit: number ограничение количества документов;
  • skip: number пропуск документов (пагинация);
  • fields: string|string[] выбор необходимых свойств модели;
  • include: object включение связанных данных в результат;

Пример

// для запроса используется метод репозитория "find"
// с передачей объекта фильтрации первым аргументом
const news = await newsRepository.find({
  where: {
    title: {like: '%Moscow%'},
    publishedAt: {gte: '2025-10-15T00:00:00.000Z'},
    tags: {inq: ['world', 'politic']},
    hidden: false,
  },
  order: 'publishedAt DESC',
  limit: 12,
  skip: 24,
  fields: ['title', 'annotation', 'body'],
  include: ['author', 'category'],
})

where

Параметр принимает объект с условиями выборки и поддерживает следующий набор операторов сравнения.

  • Поиск по значению
  • eq (строгое равенство)
  • neq (неравенство)
  • gt (больше чем)
  • lt (меньше чем)
  • gte (больше или равно)
  • lte (меньше или равно)
  • inq (в списке)
  • nin (не в списке)
  • between (диапазон)
  • exists (наличие свойства)
  • like (шаблон)
  • nlike (исключающий шаблон)
  • ilike (регистронезависимый шаблон)
  • nilike (регистронезависимый шаблон исключения)
  • regexp (регулярное выражение)

Условия можно объединять логическими операторами:

  • and (логическое И)
  • or (логическое ИЛИ)

Поиск по значению (сокращенная форма)

Находит документы, у которых значение указанного свойства в точности равно переданному значению. Это сокращенная запись для оператора {eq: ...}.

// найдет все документы, где age равен 21
const res = await rep.find({
  where: {
    age: 21,
  },
});

eq (строгое равенство)

Находит документы, у которых значение свойства равно указанному.

// найдет все документы, где age равен 21
const res = await rep.find({
  where: {
    age: {eq: 21},
  },
});

neq (неравенство)

Находит документы, у которых значение свойства не равно указанному.

// найдет все документы, где age не равен 21
const res = await rep.find({
  where: {
    age: {neq: 21},
  },
});

gt (больше чем)

Находит документы, у которых значение свойства строго больше указанного.

// найдет документы, где age больше 30
const res = await rep.find({
  where: {
    age: {gt: 30},
  },
});

lt (меньше чем)

Находит документы, у которых значение свойства строго меньше указанного.

// найдет документы, где age меньше 30
const res = await rep.find({
  where: {
    age: {lt: 30},
  },
});

gte (больше или равно)

Находит документы, у которых значение свойства больше или равно указанному.

// найдет документы, где age больше или равен 30
const res = await rep.find({
  where: {
    age: {gte: 30},
  },
});

lte (меньше или равно)

Находит документы, у которых значение свойства меньше или равно указанному.

// найдет документы, где age меньше или равен 30
const res = await rep.find({
  where: {
    age: {lte: 30},
  },
});

inq (в списке)

Находит документы, у которых значение свойства совпадает с одним из значений в предоставленном массиве.

// найдет документы, где name - 'John' или 'Mary'
const res = await rep.find({
  where: {
    name: {inq: ['John', 'Mary']},
  },
});

nin (не в списке)

Находит документы, у которых значение свойства отсутствует в предоставленном массиве.

// найдет все документы, кроме тех, где name - 'John' или 'Mary'
const res = await rep.find({
  where: {
    name: {nin: ['John', 'Mary']},
  },
});

between (диапазон)

Находит документы, у которых значение свойства находится в указанном диапазоне (включая границы).

// найдет документы, где age находится в диапазоне от 20 до 30 включительно
const res = await rep.find({
  where: {
    age: {between: [20, 30]},
  },
});

exists (наличие свойства)

Проверяет наличие или отсутствие свойства в документе. Не проверяет значение свойства.

  • true свойство должно существовать (даже если его значение null);
  • false свойство должно отсутствовать;
// найдет документы, у которых есть свойство 'nickname'
const res1 = await rep.find({
  where: {
    nickname: {exists: true},
  },
});

// найдет документы, у которых нет свойства 'nickname'
const res2 = await rep.find({
  where: {
    nickname: {exists: false},
  },
});

like (шаблон)

Выполняет сопоставление с шаблоном, с учетом регистра (см. подробнее).

// найдет {name: 'John Doe'}, но не {name: 'john doe'}
const res = await rep.find({
  where: {
    name: {like: 'John%'},
  },
});

nlike (исключающий шаблон)

Находит документы, которые не соответствуют шаблону, с учетом регистра (см. подробнее).

// найдет все, кроме тех, что начинаются на 'John'
const res = await rep.find({
  where: {
    name: {nlike: 'John%'},
  },
});

ilike (регистронезависимый шаблон)

Выполняет сопоставление с шаблоном без учета регистра (см. подробнее).

// найдет {name: 'John Doe'} и {name: 'john doe'}
const res = await rep.find({
  where: {
    name: {ilike: 'john%'},
  },
});

nilike (регистронезависимый шаблон исключения)

Находит строки, которые не соответствуют шаблону, без учета регистра (см. подробнее).

// найдет все, кроме тех, что начинаются на 'John' или 'john'
const res = await rep.find({
  where: {
    name: {nilike: 'john%'},
  },
});

regexp (регулярное выражение)

Находит документы, у которых значение строкового свойства соответствует указанному регулярному выражению. Может быть передано в виде строки или объекта RegExp.

// найдет документы, где name начинается с 'J'
const res1 = await rep.find({
  where: {
    name: {regexp: '^J'},
  },
});

// найдет документы, где name начинается с 'J' или 'j' (регистронезависимо)
const res2 = await rep.find({
  where: {
    name: {regexp: '^j', flags: 'i'},
  },
});

and (логическое И)

Объединяет несколько условий в массив, требуя, чтобы каждое условие было выполнено.

// найдет документы, где surname равен 'Smith' И age равен 21
const res = await rep.find({
  where: {
    and: [
      {surname: 'Smith'},
      {age: 21}
    ],
  },
});

or (логическое ИЛИ)

Объединяет несколько условий в массив, требуя, чтобы хотя бы одно из них было выполнено.

// найдет документы, где name равен 'James' ИЛИ age больше 30
const res = await rep.find({
  where: {
    or: [
      {name: 'James'},
      {age: {gt: 30}}
    ],
  },
});

Операторы сопоставления с шаблоном

Операторы like, nlike, ilike, nilike предназначены для фильтрации строковых свойств на основе сопоставления с шаблоном, подобно оператору LIKE в SQL. Они позволяют находить значения, которые соответствуют определённой структуре, используя специальные символы.

% соответствует любой последовательности из нуля или более символов:

  • 'А%' найдет все строки, начинающиеся на "А";
  • '%а' найдет все строки, заканчивающиеся на "а";
  • '%слово%' найдет все строки, содержащие "слово" в любом месте;

_ соответствует ровно одному любому символу:

  • 'к_т' найдет "кот", "кит", но не "крот" или "кт";
  • 'кот_' найдет "коты", "коту" и "кота", но не "кот" или "котов";

Если нужно найти сами символы % или _ как часть строки, их необходимо экранировать с помощью обратного слэша \:

  • '100\%' найдет строку "100%";
  • 'file\_name' найдет строку "file_name";
  • 'path\\to' найдет строку "path\to";

order

Параметр упорядочивает выборку по указанным свойствам модели. Обратное направление порядка можно задать постфиксом DESC в названии свойства.

Примеры

Упорядочить по полю createdAt

const res = await rep.find({
  order: 'createdAt',
});

Упорядочить по полю createdAt в обратном порядке.

const res = await rep.find({
  order: 'createdAt DESC',
});

Упорядочить по нескольким свойствам в разных направлениях.

const res = await rep.find({
  order: [
    'title',
    'price ASC',
    'featured DESC',
  ],
});

i. Направление порядка ASC указывать необязательно.

include

Параметр включает связанные документы в результат вызываемого метода. Названия включаемых связей должны быть определены в текущей модели. (см. Связи)

Примеры

Включение связи по названию.

const res = await rep.find({
  include: 'city',
});

Включение вложенных связей.

const res = await rep.find({
  include: {
    city: 'country',
  },
});

Включение нескольких связей массивом.

const res = await rep.find({
  include: [
    'city',
    'address',
    'employees'
  ],
});

Использование фильтрации включаемых документов.

const res = await rep.find({
  include: {
    relation: 'employees', // название связи
    scope: { // фильтрация документов "employees"
      where: {hidden: false}, // условия выборки
      order: 'id', // порядок документов
      limit: 10, // ограничение количества
      skip: 5, // пропуск документов
      fields: ['name', 'surname'], // только указанные поля
      include: 'city', // включение связей для "employees"
    },
  },
});

Связи

Связи позволяют описывать отношения между моделями, что дает возможность автоматически встраивать связанные данные с помощью опции include в методах репозитория. Ниже приводится пример автоматического разрешения связи при использовании метода findById.

         Роль (role)
      ┌────────────────────┐
      │  id: 3  <──────────│────┐
      │  name: 'Manager'   │    │
      └────────────────────┘    │
                                │
     Пользователь (user)        │
  ┌────────────────────────┐    │
  │  id: 1                 │    │
  │  name: 'John Doe'      │    │
  │  roleId: 3   ──────────│────┘
  │  cityId: 24  ──────────│────┐
  └────────────────────────┘    │
                                │
         Город (city)           │
      ┌────────────────────┐    │
      │  id: 24  <─────────│────┘
      │  name: 'Moscow'    │
      └────────────────────┘
// запрос документа коллекции "users",
// включая связанные данные (role и city)
const user = await userRep.findById(1, {
  include: ['role', 'city'],
});

console.log(user);
// {
//   id: 1,
//   name: 'John Doe',
//   roleId: 3,
//   role: {
//     id: 3,
//     name: 'Manager'
//   },
//   cityId: 24,
//   city: {
//     id: 24,
//     name: 'Moscow'
//   }
// }

Определение связи

Свойство relations в определении модели принимает объект, ключи которого являются названиями связей, а значения их параметрами. В дальнейшем название связи можно будет использовать в опции include методах репозитория.

import {
  DataType,
  RelationType,
  DatabaseSchema,
} from '@e22m4u/js-repository';

dbs.defineModel({
  name: 'user',
  datasource: 'memory',
  properties: {
    name: DataType.STRING,
  },
  relations: {
    // связь role -> параметры
    role: {
      type: RelationType.BELONGS_TO,
      model: 'role',
    },
    // связь city -> параметры
    city: {
      type: RelationType.BELONGS_TO,
      model: 'city',
    },
  },
});

Основные параметры

  • type: string тип связи (обязательно);
  • model: string название целевой модели (обязательно для некоторых типов);
  • foreignKey: string свойство текущей модели для идентификатора цели;

i. Для типов Belongs To и References Many значение параметра foreignKey можно опустить, так как генерируется автоматически по названию связи.

Полиморфный режим

  • polymorphic: boolean|string объявление полиморфной связи;
  • discriminator: string свойство текущей модели для названия цели;

i. Полиморфный режим позволяет динамически определять целевую модель по ее названию, которое хранит документ в свойстве-дискриминаторе.

Типы связей

  • Belongs To
    Текущая модель ссылается на целевую по идентификатору.
    type: "belongsTo" или type: RelationType.BELONGS_TO

  • Has One
    Обратная сторона belongsTo по принципу "один к одному".
    type: "hasOne" или type: RelationType.HAS_ONE

  • Has Many
    Обратная сторона belongsTo по принципу "один ко многим".
    type: "hasMany" или type: RelationType.HAS_MANY

  • References Many
    Текущая модель ссылается на целевую через массив идентификаторов.
    type: "referencesMany" или type: RelationType.REFERENCES_MANY

Полиморфные версии:

Параметр type в определении связи принимает строку с названием типа. Чтобы исключить опечатку, рекомендуется использовать константы объекта RelationType указанные ниже.

  • RelationType.BELONGS_TO
  • RelationType.HAS_ONE
  • RelationType.HAS_MANY
  • RelationType.REFERENCES_MANY

Belongs To

Текущая модель ссылается на целевую по идентификатору.

    Текущая (user)                  Целевая (role)
┌─────────────────────────┐       ┌─────────────────────────┐
│   id: 1                 │   ┌───│─> id: 5                 │
│   roleId: 5  ───────────│───┤   │   ...                   │
│   ...                   │   │   └─────────────────────────┘
└─────────────────────────┘   │ 
┌─────────────────────────┐   │   
│   id: 2                 │   │
│   roleId: 5  ───────────│───┘   
│   ...                   │       
└─────────────────────────┘       

Определение связи:

dbs.defineModel({
  name: 'user',
  relations: {
    role: { // название связи
      type: RelationType.BELONGS_TO, // текущая модель ссылается на целевую
      model: 'role', // название целевой модели
      foreignKey: 'roleId', // внешний ключ (необязательно)
      // если "foreignKey" не указан, то свойство внешнего
      // ключа формируется согласно названию связи
      // с добавлением постфикса "Id"
    },
  },
});

Пример:

import {
  DataType,
  RelationType,
  DatabaseSchema,
} from '@e22m4u/js-repository';

const dbs = new DatabaseSchema();

// источник данных
dbs.defineDatasource({
  name: 'myDb',
  adapter: 'memory',
});

// модель роли
dbs.defineModel({
  name: 'role',
  datasource: 'myDb',
  properties: {
    name: DataType.STRING,
  },
});

// модель пользователя
dbs.defineModel({
  name: 'user',
  datasource: 'myDb',
  properties: {
    name: DataType.STRING,
    roleId: DataType.NUMBER, // не обязательно
  },
  relations: {
    role: {
      type: RelationType.BELONGS_TO,
      model: 'role',
      foreignKey: 'roleId', // не обязательно
    },
  },
});

// создание роли
const roleRep = dbs.getRepository('role');
const role = await roleRep.create({
  id: 5,
  name: 'Manager',
});
console.log(role);
// {
//   id: 5,
//   name: 'manager'
// }

// создание пользователя
const userRep = dbs.getRepository('user');
const user = await userRep.create({
  id: 1,
  name: 'John Doe',
  roleId: role.id,
});
console.log(user);
// {
//   id: 1,
//   name: 'John Doe',
//   roleId: 5
// }

// извлечение пользователя и связанной роли (опция "include")
const userWithRole = await userRep.findById(user.id, {include: 'role'});
console.log(userWithRole);
// {
//   id: 1,
//   name: 'John Doe',
//   roleId: 5,
//   role: {
//     id: 5,
//     name: 'Manager'
//   }
// }

Has One

Обратная сторона belongsTo по принципу "один к одному".

    Текущая (profile)               Целевая (user)
┌─────────────────────────┐       ┌─────────────────────────┐
│   id: 5  <──────────────│───┐   │   id: 1                 │
│   ...                   │   └───│── profileId: 5          │
└─────────────────────────┘       │   ...                   │
                                  └─────────────────────────┘

Определение связи:

// dbs.defineModel({
//   name: 'user',
//   relations: {
//     profile: {
//       type: RelationType.BELONGS_TO,
//       model: 'profile',
//     },
//   },
// });

dbs.defineModel({
  name: 'profile',
  relations: {
    user: { // название связи
      type: RelationType.HAS_ONE, // целевая модель ссылается на текущую
      model: 'user', // название целевой модели
      foreignKey: 'profileId', // внешний ключ из целевой модели на текущую
    },
  },
});

Has Many

Обратная сторона belongsTo по принципу "один ко многим".

    Текущая (role)                  Целевая (user)
┌─────────────────────────┐       ┌─────────────────────────┐
│   id: 5  <──────────────│───┐   │   id: 1                 │
│   ...                   │   ├───│── roleId: 5             │
└─────────────────────────┘   │   │   ...                   │
                              │   └─────────────────────────┘
                              │   ┌─────────────────────────┐
                              │   │   id: 2                 │
                              └───│── roleId: 5             │
                                  │   ...                   │
                                  └─────────────────────────┘

Определение связи:

// dbs.defineModel({
//   name: 'user',
//   relations: {
//     role: {
//       type: RelationType.BELONGS_TO,
//       model: 'role',
//     },
//   },
// });

dbs.defineModel({
  name: 'role',
  relations: {
    users: { // название связи
      type: RelationType.HAS_MANY, // целевая модель ссылается на текущую
      model: 'user', // название целевой модели
      foreignKey: 'roleId', // внешний ключ целевой модели
    },
  },
});

References Many

Текущая модель ссылается на целевую через массив идентификаторов.

    Текущая (article)                 Целевая (category)
┌─────────────────────────┐       ┌─────────────────────────┐
│   id: 1                 │   ┌───│─> id: 5                 │
│   categoryIds: [5, 6] ──│───┤   │   ...                   │
│   ...                   │   │   └─────────────────────────┘
└─────────────────────────┘   │   ┌─────────────────────────┐
                              └───│─> id: 6                 │
                                  │   ...                   │
                                  └─────────────────────────┘

Определение связи:

// dbs.defineModel({name: 'category', ...

dbs.defineModel({
  name: 'article',
  relations: {
    categories: { // название связи
      type: RelationType.REFERENCES_MANY, // связь через массив идентификаторов
      model: 'category', // название целевой модели
      foreignKey: 'categoryIds', // внешний ключ (необязательно)
      // если "foreignKey" не указан, то свойство внешнего
      // ключа формируется согласно названию связи
      // с добавлением постфикса "Ids"
    },
  },
});

Belongs To (полиморфная версия)

Текущая модель ссылается на целевую по идентификатору. Название целевой модели определяется свойством-дискриминатором.

    Текущая (file)               ┌──────> Целевая 1 (letter)
┌─────────────────────────────┐  │    ┌─────────────────────────┐
│   id: 1                     │  │ ┌──│─> id: 10                │
│   referenceType: 'letter'  ─│──┘ │  │   ...                   │
│   referenceId: 10  ─────────│────┘  └─────────────────────────┘
└─────────────────────────────┘
                                 ┌──────> Целевая 2 (user)
┌─────────────────────────────┐  │    ┌─────────────────────────┐
│   id: 2                     │  │ ┌──│─> id: 5                 │
│   referenceType: 'user'  ───│──┘ │  │   ...                   │
│   referenceId: 5  ──────────│────┘  └─────────────────────────┘
└─────────────────────────────┘

Определение связи:

dbs.defineModel({
  name: 'file',
  relations: {
    reference: { // название связи
      type: RelationType.BELONGS_TO, // текущая модель ссылается на целевую
      // полиморфный режим позволяет хранить название целевой модели
      // в свойстве-дискриминаторе, которое формируется согласно
      // названию связи с постфиксом "Type", и в данном случае
      // название целевой модели хранит "referenceType",
      // а идентификатор документа "referenceId"
      polymorphic: true,
    },
  },
});

Определение связи с указанием свойств:

dbs.defineModel({
  name: 'file',
  relations: {
    reference: { // название связи
      type: RelationType.BELONGS_TO, // текущая модель ссылается на целевую
      polymorphic: true, // название целевой модели хранит дискриминатор
      foreignKey: 'referenceId', // свойство для идентификатора цели
      discriminator: 'referenceType', // свойство для названия целевой модели
    },
  },
});

Has One (полиморфная версия)

Обратная сторона полиморфная связи belongsTo по принципу "один к одному".

    Текущая (company)  <───────┐      Целевая (license)
┌─────────────────────────┐    │  ┌─────────────────────────┐
│   id: 10  <─────────────│──┐ │  │   id: 1                 │
│   ...                   │  │ └──│── ownerType: 'company'  │
└─────────────────────────┘  └────│── ownerId: 10           │
                                  └─────────────────────────┘

Определение связи с указанием названия связи целевой модели:

// dbs.defineModel({
//   name: 'license',
//   relations: {
//     owner: {
//       type: RelationType.BELONGS_TO,
//       polymorphic: true,
//     },
//   },
// });

dbs.defineModel({
  name: 'company',
  relations: {
    license: { // название связи
      type: RelationType.HAS_ONE, // целевая модель ссылается на текущую
      model: 'license', // название целевой модели
      polymorphic: 'owner', // название полиморфной связи целевой модели
    },
  },
});

Определение связи с указанием свойств целевой модели:

// dbs.defineModel({
//   name: 'license',
//   relations: {
//     owner: {
//       type: RelationType.BELONGS_TO,
//       polymorphic: true,
//       foreignKey: 'ownerId',
//       discriminator: 'ownerType',
//     },
//   },
// });

dbs.defineModel({
  name: 'company',
  relations: {
    license: { // название связи
      type: RelationType.HAS_ONE, // целевая модель ссылается на текущую
      model: 'license', // название целевой модели
      polymorphic: true, // название текущей модели находится в дискриминаторе
      foreignKey: 'ownerId', // свойство целевой модели для идентификатора
      discriminator: 'ownerType', // свойство целевой модели для названия текущей
    },
  },
});

Has Many (полиморфная версия)

Обратная сторона полиморфная связи belongsTo по принципу "один ко многим".

    Текущая (letter)  <─────────┐      Целевая (file)
┌──────────────────────────┐    │  ┌────────────────────────────┐
│   id: 10  <──────────────│──┐ │  │   id: 1                    │
│   ...                    │  │ ├──│── referenceType: 'letter'  │
└──────────────────────────┘  ├─│──│── referenceId: 10          │
                              │ │  └────────────────────────────┘
                              │ │  ┌────────────────────────────┐
                              │ │  │   id: 2                    │
                              │ └──│── referenceType: 'letter'  │
                              └────│── referenceId: 10          │
                                   └────────────────────────────┘

Определение связи с указанием названия связи целевой модели:

// dbs.defineModel({
//   name: 'file',
//   relations: {
//     reference: {
//       type: RelationType.BELONGS_TO,
//       polymorphic: true,
//     },
//   },
// });

dbs.defineModel({
  name: 'letter',
  relations: {
    attachments: { // название связи
      type: RelationType.HAS_MANY, // целевая модель ссылается на текущую
      model: 'file', // название целевой модели
      polymorphic: 'reference', // название полиморфной связи целевой модели
    },
  },
});

Определение связи с указанием свойств целевой модели:

// dbs.defineModel({
//   name: 'file',
//   relations: {
//     reference: {
//       type: RelationType.BELONGS_TO,
//       polymorphic: true,
//       foreignKey: 'referenceId',
//       discriminator: 'referenceType',
//     },
//   },
// });

dbs.defineModel({
  name: 'letter',
  relations: {
    attachments: { // название связи
      type: RelationType.HAS_MANY, // целевая модель ссылается на текущую
      model: 'file', // название целевой модели
      polymorphic: true, // название текущей модели находится в дискриминаторе
      foreignKey: 'referenceId', // свойство целевой модели для идентификатора
      discriminator: 'referenceType', // свойство целевой модели для названия текущей
    },
  },
});

Расширение

Метод getRepository экземпляра DatabaseSchema проверяет наличие существующего репозитория для указанной модели и возвращает его. В противном случае создается новый экземпляр, который будет сохранен для последующих обращений к методу.

import {Repository} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';

// const dbs = new DatabaseSchema();
// dbs.defineDatasource ...
// dbs.defineModel ...

const rep1 = dbs.getRepository('model');
const rep2 = dbs.getRepository('model');
console.log(rep1 === rep2); // true

Подмена стандартного конструктора репозитория выполняется методом setRepositoryCtor сервиса RepositoryRegistry, который находится в сервис-контейнере экземпляра DatabaseSchema. После чего все новые репозитории будут создаваться указанным конструктором вместо стандартного.

import {Repository} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';
import {RepositoryRegistry} from '@e22m4u/js-repository';

class MyRepository extends Repository {
  /*...*/
}

// const dbs = new DatabaseSchema();
// dbs.defineDatasource ...
// dbs.defineModel ...

dbs.getService(RepositoryRegistry).setRepositoryCtor(MyRepository);
const rep = dbs.getRepository('model');
console.log(rep instanceof MyRepository); // true

i. Так как экземпляры репозитория кэшируется, то замену конструктора следует выполнять до обращения к методу getRepository.

TypeScript

Получение типизированного репозитория с указанием интерфейса модели.

import {DataType} from '@e22m4u/js-repository';
import {RelationType} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';

// const dbs = new DatabaseSchema();
// dbs.defineDatasource ...

// определение модели "city"
dbs.defineModel({
  name: 'city',
  datasource: 'myDatasource',
  properties: {
    name: DataType.STRING,
    timeZone: DataType.STRING,
  },
});

// определение интерфейса "city"
interface City {
  id: number;
  name?: string;
  timeZone?: string;
}

// при получении репозитория нужной модели
// можно указать тип документов
const cityRep = dbs.getRepository<City>('city');

// теперь, методы репозитория возвращают
// тип City вместо Record<string, unknown>
const city: City = await cityRep.create({
  name: 'Moscow',
  timeZone: 'Europe/Moscow',
});

Для определения моделей с помощью TypeScript классов, рекомендуется использовать специальную версию данного модуля @e22m4u/ts-repository, поставляемую с набором TypeScript декораторов и дополнительных инструментов для работы в TypeScript окружении.

Тесты

npm run test

Лицензия

MIT