Главная » Хабрахабр » Maraquia — ORM для MongoDB

Maraquia — ORM для MongoDB

д.? После прочтения заголовка у многих наверняка возникает вопрос — зачем ещё один велосипед при наличии уже обкатанных Mongoose, Mongorito, TypeORM и т. Смотрим википедию: Для ответа нужно разобраться в чём отличие ORM от ODM.

Object-Relational Mapping, рус. ORM (англ. объектно-реляционное отображение, или преобразование) — технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования, создавая «виртуальную объектную базу данных».

Напомню, в реляционных БД нет возможности просто взять и встроить документ в поле другого документа (в этой статье записи таблиц тоже называются документами, хоть это и некорректно), можно конечно хранить в поле JSON в виде строки, но индекс по данным в нём сделать не выйдет. То есть ORM — это именно про реляционное представление данных. ORM умеет работать с такими ссылками — записи по ним автоматически сразу или лениво забираются из БД, а при сохранении не нужно сперва сохранять дочерний документ, брать назначенный ему идентификатор, записывать его в поле родительского документа и только после этого сохранять родительский документ. Вместо этого используются "ссылки" — в поле, где должен быть вложенный документ, вместо него записывается его идентификатор, а сам документ с этим идентификатором сохраняется в соседней таблице. ODM же наоборот, не умеет работать с такими ссылками, зато знает про встроенные документы. Нужно просто попросить ORM сохранить родительский документ и всё что с ним связано, а он (object-relational mapper) уже сам разберётся как это правильно сделать.

Даже TypeORM при работе с MongoDB имеет некоторые ограничения (https://github.com/typeorm/typeorm/issues/655) которые делают его опять же обычным ODM. Думаю отличия примерно понятны, так вот всё перечисленное выше является именно ODM.

Зачем работая с документоориентированной БД мне понадобились какие-то ссылки? И тут вы спросите — а зачем? Есть минимум одна простая, но часто встречающаяся ситуация когда они всё же необходимы: на дочерний документ указывают несколько родительских, здесь можно каждому родительскому записать по копии дочернего и потом страдать обеспечивая консистентность данных в этих копиях, а можно просто сохранить дочерний документ в отдельной коллекции, а всем родителям дать ссылку на него (можно ещё в дочерний встраивать родительский, но это не всегда возможно, во-первых, отношение может быть many-to-many, во-вторых, дочерний тип может быть слишком второстепенен в системе и завтра может вообще исчезнуть из БД, встраивать в него что-то ключевое совсем не хочется).

Я решил посмотреть в сторону MongoDB и первое чего я не обнаружил, это подобных пакетов. Долгое время я работал с RethinkDB для которого есть несколько неплохих ORM (thinky, requelize, ...), но последнее время активность разработки этой БД совсем уж вызывает уныние. Почему бы не написать самому, это будет довольно интересный опыт, подумал я и, встречайте — Maraquia.

Установка

npm i -S @riim/maraquia

При использовании с typescript необходимо также добавить "experimentalDecorators": true в tsconfig.json.

Настройка соединения

Есть два способа, здесь рассмотрим более простой: в папке проекта создаём файл config/maraquia.json в который добавляем следующее:

{ "databaseUrl": "mongodb://localhost:27017/", "databaseName": "Test"
}

Использование

Сохранение в БД

Простой пример отношения one-to-many со ссылкой только в одну сторону (примеры будут на typescript, в конце пример на javascript):

import from '@riim/maraquia'; @Model({ collectionName: 'Pet'
})
class Pet extends BaseModel { @Field() name: string | null;
} @Model({ collectionName: 'Owner'
})
class Owner extends BaseModel { @Field() name: string | null; @Field(() => Pet) pets: Promise<Array<Pet> | null>;
} (async () => { // в следующих примерах я буду опускать эту строчку let pet = new Pet({ name: 'Tatoshka'
}); let owner = new Owner({ name: 'Dmitry', pets: [pet] }); await owner.save(); })();

В БД появятся две коллекции Pet и Owner с записями:

{ "_id": "5a...1f44", "name": "Tatoshka"
}

и

{ "_id": "5a...1f43", "name": "Dmitry", "pets": ["5a...1f44"] }

Метод save был вызван только на модели owner, Maraquia как и положено сама позаботилась о сохранении второго документа.

Усложним пример, теперь отношение many-to-many и ссылки в обе стороны:

@Model({ collectionName: 'User'
})
class User extends BaseModel { @Field() name: string | null; @Field(() => Group) groups: Promise<Array<Group> | null>;
} @Model({ collectionName: 'Group'
})
class Group extends BaseModel { @Field() name: string | null; @Field(() => User) users: Promise<Array<User> | null>;
} let user1 = new User({ name: 'Dmitry'
});
let user2 = new User({ name: 'Tatoshka'
}); let group1 = new Group({ name: 'Admins', users: [user1] });
let group2 = new Group({ name: 'Moderators', users: [user1, user2] }); user1.groups = [group1, group2] as any;
user2.groups = [group2] as any; await group1.save();

В БД появится коллекция User с записями:

{ "_id": "5a...c56f", "name": "Dmitry", "groups": ["5a...c56e", "5a...c570"] } { "_id": "5a...c571", "name": "Tatoshka", "groups": ["5a...c570"] }

и коллекция Group с записями:

{ "_id": "5a...c56e", "name": "Admins", "users": ["5a...c56f"] } { "_id": "5a...c570", "name": "Moderators", "users": ["5a...c56f", "5a...c571"] }

Maraquia справляется без этой дополнительной информации, hasOne или hasMany определяется значением, массив — значит hasMany. Вы, наверное, уже заметили отсутствие декораторов с именами вроде hasOne, hasMany, belongsTo как это обычно принято для ORM. Например, если в первом примере закомментировать строку collectionName: 'Pet' и вновь запустить его, то запись появится только в коллекции Owner и будет выглядеть так: А встроенный документ или внешний (сохраняется в отдельной коллекции) определяется наличием в его схеме заполненного collectionName.

{ "_id": "5b...ec43", "name": "Dmitry", "pets": [{ "name":"Tatoshka" }] }

Кроме того тип поля pets перестаёт быть промисом.
То есть с помощью Maraquia можно также удобно работать и со встраиваемыми документами.

Чтение из БД

Попробуем прочитать из базы что-то из ранее сохранённого:

let user = User.find<User>({ name: 'Dmitry' }); console.log(user instanceof User); // true console.log(user.name); // 'Dmitry'
console.log(await user.groups); // [Group { name: 'Admins', ... }, Group { name: 'Moderators', ... }]

При чтении поля groups было использовано ключевое слово await — внешние документы достаются из базы лениво при первом чтении соответствующего поля.

Имя поля в модели соответствует имени поля в документе, но используя опцию dbFieldName можно изменить это соответствие. Но что если необходимо иметь доступ к идентификаторам хранящимся в поле без вытаскивания соответствующих им документов из БД, но при этом опционально может понадобиться и вытащить их? То есть определив два поля в модели ссылающихся на одно поле в документе и не указав тип для одного из них можно решить эту проблему:

@Model({ collectionName: 'Group'
})
class Group extends BaseModel { @Field({ dbFieldName: 'users' }) readonly userIds: Array<ObjectId> | null; // здесь будут идентификаторы @Field(() => User) users: Promise<Array<User> | null>; // а здесь инстансы пользователей по идентификаторам
}

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

Maraquia не знает где есть ссылки на него и здесь программисту необходимо поработать самому: Метод remove удаляет соответствующий документ из БД.

@Model({ collectionName: 'User'
})
class User extends BaseModel { @Field() name: string | null; @Field({ dbFieldName: 'groups' }) groupIds: Array<ObjectId> | null; @Field(() => Group) groups: Promise<Array<Group> | null>;
} @Model({ collectionName: 'Group'
})
class Group extends BaseModel { @Field() name: string | null; @Field({ dbFieldName: 'users' }) userIds: Array<ObjectId> | null; @Field(() => User) users: Promise<Array<User> | null>;
} let user = (await User.find<User>({ name: 'Tatoshka' }))!; // удаляем ссылки на документ
for (let group of await Group.findAll<Group>({ _id: { $in: user.groupIds } })) { group.userIds = group.userIds!.filter( userId => userId.toHexString() != user._id!.toHexString() ); await group.save();
} // удаляем сам документ
await user.remove();

То есть можно было так: В данном примере массив userIds был заменён на новый, созданный методом Array#filter, но можно менять существующий массив, Maraquia находит и такие изменения.

group.userIds!.splice( group.userIds!.findIndex(userId => userId.toHexString() == user._id!.toHexString()), 1
);

Валидация

Для валидации поля необходимо добавить свойство validate в его опции:

@Model({ collectionName: 'User'
})
class User extends BaseModel { @Field({ validate: value => typeof value == 'string' && value.trim().length >= 2 }) name: string | null; @Field({ validate: value => { // При false сообщение об ошибке сформируется автоматически: return typeof value == 'number' && value >= 0; // Или можно задать его самостоятельно: if (typeof value != 'number' || value < 0) { return 'Что-то пошло не так'; // или так: return new TypeError('Что-то пошло не так'); // или так: throw new TypeError('Что-то пошло не так'); } } }) age: number | null; @Field(() => Account, { validate: value => !!value }) /* тоже самое что и: @Field({ type: () => Account, validate: value => !!value }) */ account: Promise<Account | null>;
}

Так же можно передавать объекты создаваемые библиотекой joi:

import * as joi from 'joi'; @Model({ collectionName: 'User'
})
class User extends BaseModel { @Field({ validate: joi.string().min(2) }) name: string | null; @Field({ validate: joi.number().min(0) }) age: number | null;
}

Хуки

Следующие методы срабатывают согласно их названию: beforeSave, afterSave, beforeRemove, afterRemove.

Использование с javascript

Для этого вместо объекта передаваемого декоратору Model необходимо определить статическое поле $schema, в котором есть также поле fields: Typescript — это здорово, но иногда надо без него.

const { BaseModel } = require('@riim/maraquia'); class Pet extends BaseModel {
}
Pet.$schema = { collectionName: 'Pet', fields: { name: {} }
}; class Owner extends BaseModel {
}
Owner.$schema = { collectionName: 'Owner', fields: { name: {}, pets: { type: () => Pet } }
}; let pet = new Pet({ name: 'Tatoshka'
}); let owner = new Owner({ name: 'Dmitry', pets: [pet] }); await owner.save();

Запись в поля делается через метод setField:

pet.setField('name', 'Tosha');

А чтение полей с внешними документами через метод fetchField:

await owner.fetchField('pets');

Остальные поля читаются как обычно.

Производительность

В первом просто создаются экземпляры модели. Я написал парочку простых бенчмарков для сравнения производительности с Mongoose и Mongorito. Для всех троих это выглядит одинаково:

let cat = new 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.

Футер

Если что-то работает не так как ожидается, создавайте issue на github. Надеюсь кому-то библиотека окажется полезна, она пока ещё не применялась на реальных проектах, так что используйте на свой страх и риск.

В основном я занимаюсь front-end разработкой и вряд ли хорошо разбираюсь в базах данных, так что если где-то написал чушь, то прошу понять и простить :).

Благодарю за внимание.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Сортировки всех времён и народов

Несколько лет назад написал небольшой макрос, чтобы создавать алгоритмическую gif-анимацию для хабрастатей. То, о чём так долго говорили большевики и к чему я с разным темпом шёл несколько лет, наконец-то свершилось. Со временем мой скромный инструмент разросся до внушительных размеров, ...

Security Week 22: двухсекундные умные замки

Прошедшая неделя отметилась парой интересных новостей из небезопасного мира интернет-вещей, а главным событием, конечно, стало исследование умного замка Tapplock (новость, оригинальный отчет). Исследователь из Pen Test Partners Эндрю Тирни не просто обошел защиту электронного замка, а скорее обнаружил полное отсутствие ...