Maraquia -- a simple ORM for MongoDB
После прочтения заголовка у многих наверняка возникает вопрос -- зачем ещё один велосипед при наличии уже обкатанных Mongoose, Mongorito, TypeORM и т. д.? Для ответа нужно разобраться в чём отличие ORM от ODM. Смотрим википедию:
ORM (англ. Object-Relational Mapping, рус. объектно-реляционное отображение, или преобразование) — технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования, создавая «виртуальную объектную базу данных».
То есть ORM -- это именно про реляционное представление данных. Напомню, в реляционных БД нет возможности просто взять и встроить документ в поле другого документа (в этой статье записи таблиц тоже называются документами, хоть это и некорректно), можно конечно хранить в поле JSON в виде строки, но индекс по данным в нём сделать не выйдет. Вместо этого используются "ссылки" -- в поле, где должен быть вложенный документ, вместо него записывается его идентификатор, а сам документ с этим идентификатором сохраняется в соседней таблице. ORM умеет работать с такими ссылками -- записи по ним автоматически сразу или лениво забираются из БД, а при сохранении не нужно сперва сохранять дочерний документ, брать назначенный ему идентификатор, записывать его в поле родительского документа и только после этого сохранять родительский документ. Нужно просто попросить ORM сохранить родительский документ и всё что с ним связано, а он (object-relational mapper) уже сам разберётся как это правильно сделать. ODM же наоборот, не умеет работать с такими ссылками, зато знает про встроенные документы.
Думаю отличия примерно понятны, так вот всё перечисленное выше является именно ODM. Даже TypeORM при работе с MongoDB имеет некоторые ограничения (https://github.com/typeorm/typeorm/issues/655) которые делают его опять же обычным ODM.
И тут вы спросите -- а зачем? Зачем работая с документоориентированной БД мне понадобились какие-то ссылки? Есть минимум одна простая, но часто встречающаяся ситуация когда они всё же необходимы: на дочерний документ указывают несколько родительских, здесь можно каждому родительскому записать по копии дочернего и потом страдать обеспечивая консистентность данных в этих копиях, а можно просто сохранить дочерний документ в отдельной коллекции, а всем родителям дать ссылку на него (можно ещё в дочерний встраивать родительский, но это не всегда возможно, во-первых, отношение может быть many-to-many, во-вторых, дочерний тип может быть слишком второстепенен в системе и завтра может вообще исчезнуть из БД, встраивать в него что-то ключевое совсем не хочется).
Долгое время я работал с RethinkDB для которого есть несколько неплохих ORM (thinky, requelize, ...), но последнее время активность разработки этой БД совсем уж вызывает уныние. Я решил посмотреть в сторону MongoDB и первое чего я не обнаружил, это подобных пакетов. Почему бы не написать самому, это будет довольно интересный опыт, подумал я и, встречайте -- Maraquia.
Установка
npm i -S maraquia
При использовании с typescript необходимо также добавить "experimentalDecorators": true
в tsconfig.json
.
Настройка соединения
Есть два способа, здесь рассмотрим более простой: в папке проекта создаём файл config/maraquia.json
в который добавляем следующее:
Использование
Сохранение в БД
Простой пример отношения one-to-many со ссылкой только в одну сторону:
; @ @ name: string | null; @ @ name: string | null; @ pets: Promise<Array<Pet> | null>; async { // в следующих примерах я буду опускать эту строчку let pet = name: 'Tatoshka'; let owner = name: 'Dmitry' pets: pet; await owner; };
В БД появятся две коллекции Pet
и Owner
с записями:
и
Метод save
был вызван только на модели owner
, Maraquia как и положено сама позаботилась о сохранении второго документа.
Усложним пример, теперь отношение many-to-many и ссылки в обе стороны:
@ @ name: string | null; @ groups: Promise<Array<Group> | null>; @ @ name: string | null; @ users: Promise<Array<User> | null>; let user1 = name: 'Dmitry';let user2 = name: 'Tatoshka'; let group1 = name: 'Admins' users: user1;let group2 = name: 'Moderators' users: user1 user2; user1groups = group1 group2 as any;user2groups = group2 as any; await group1;
В БД появится коллекция User
с записями:
и коллекция Group
с записями:
Вы, наверное, уже заметили отсутствие декораторов с именами вроде hasOne
, hasMany
, belongsTo
как это обычно принято для ORM. Maraquia справляется без этой дополнительной информации, hasOne или hasMany определяется значением, массив -- значит hasMany. А встроенный документ или внешний (сохраняется в отдельной коллекции) определяется наличием в его схеме заполненного collectionName
. Например, если в первом примере закомментировать строку collectionName: 'Pet'
и вновь запустить его, то запись появится только в коллекции Owner
и будет выглядеть так:
Кроме того тип поля pets
перестаёт быть промисом.
То есть с помощью Maraquia можно также удобно работать и со встраиваемыми документами.
Чтение из БД
Попробуем прочитать из базы что-то из ранее сохранённого:
let user = UserfindOne<User> name: 'Dmitry' ; console; // true console; // 'Dmitry'console; // [Group { name: 'Admins', ... }, Group { name: 'Moderators', ... }]
При чтении поля groups
было использовано ключевое слово await
-- внешние документы достаются из базы лениво при первом чтении соответствующего поля.
Но что если необходимо иметь доступ к идентификаторам хранящимся в поле без вытаскивания соответствующих им документов из БД, но при этом опционально может понадобиться и вытащить их? Имя поля в модели соответствует имени поля в документе, но используя опцию dbFieldName
можно изменить это соответствие. То есть определив два поля в модели ссылающихся на одно поле в документе и не указав тип для одного из них можно решить эту проблему:
@ @ readonly userIds: Array<ObjectId> | null; // здесь будут идентификаторы @ users: Promise<Array<User> | null>; // а здесь инстансы пользователей по идентификаторам
Удаление документа
Метод remove
удаляет соответствующий документ из БД. Maraquia не знает где есть ссылки на него и здесь программисту необходимо поработать самому:
@ @ name: string | null; @ groupIds: Array<ObjectId> | null; @ groups: Promise<Array<Group> | null>; @ @ name: string | null; @ userIds: Array<ObjectId> | null; @ users: Promise<Array<User> | null>; let user = await UserfindOne<User> name: 'Tatoshka' !; // удаляем ссылки на документfor let group of await Groupfind<Group> _id: $in: usergroupIds groupuserIds = groupuserIds!; await group; // удаляем сам документawait user;
В данном примере массив userIds
был заменён на новый, созданный методом Array#filter
, но можно менять существующий массив, Maraquia находит и такие изменения. То есть можно было так:
groupuserIds!;
Валидация
Для валидации поля необходимо добавить свойство validate
в его опции:
Так же можно передавать объекты создаваемые библиотекой joi:
; @ @ name: string | null; @ age: number | null;
Хуки
Следующие методы срабатывают согласно их названию: beforeSave
, afterSave
, beforeRemove
, afterRemove
.
Использование с javascript
Typescript -- это здорово, но иногда надо без него. Для этого вместо объекта передаваемого декоратору Model
необходимо определить статическое поле $schema
, в котором есть также поле fields
:
const BaseModel = ; Pet$schema = collectionName: 'Pet' fields: name: {} ; Owner$schema = collectionName: 'Owner' fields: name: {} pets: Pet ; let pet = name: 'Tatoshka'; let owner = name: 'Dmitry' pets: pet; await owner;
Запись в поля делается через метод setField
:
pet;
А чтение полей с внешними документами через метод fetchField
:
await owner;
Остальные поля читаются как обычно.
Производительность
Я написал парочку простых бенчмарков для сравнения производительности с Mongoose и Mongorito. В первом просто создаются экземпляры модели. Для всех троих это выглядит одинаково:
let cat = name: 'Tatoshka' age: 1 gender: '1' email: 'tatoshka@email.ru' phone: '+79991234567';
Результат (больше -- лучше):
Mongoose x 41,382 ops/sec ±7.38% (78 runs sampled)
Mongorito x 28,649 ops/sec ±3.20% (85 runs sampled)
Maraquia x 1,312,816 ops/sec ±1.70% (87 runs sampled)
Во втором тоже самое, но с сохранением в БД. Результат:
Mongoose x 1,125 ops/sec ±4.59% (69 runs sampled)
Mongorito x 1,596 ops/sec ±4.08% (69 runs sampled)
Maraquia x 1,143 ops/sec ±3.39% (73 runs sampled)
Исходники в папке perf.