JOIN в NoSQL базах данных
В этом сообщении будут рассмотрены способы соединения коллекций в NoSQL базах данных mongodb, arangodb, orientdb и rethinkdb (помимо того, что это NoSQL базы данных, их объединяет еще и наличие бесплатной версии с достаточно лояльной лицензией). В реляционных базах данных аналогичная функциональность реализуется при помощи SQL JOIN. Несмотря на то, что CRUD — операции в NoSQL базах данных очень похожи и различаются только в деталях, например, в одной базе данных для создания объекта используется функция create ({… }), в другой — insert ({… }), а в третьей — save ({… }), — реализация выборки из двух и более коллекций в каждой из баз данных реализована совершенно по-разному. Поэтому будет интересно выполнить на всех базах данных одинаковую выборку. Для всех баз будет рассмотрено получение выборки (связь типа многие-ко многим) для двух таблиц.
Для примера будут использованы таблицы
Автор
Книга
- Название
КнигиАвторов
- Автор
- Книга
Для mongodb выборка из таблиц будет реализована так:
const mongo = require('mongodb-bluebird');
mongo.connect("mongodb://localhost:27017/test").then(async function(db) {
const author = db.collection('author');
const book = db.collection('book');
const bookauthor = db.collection('bookauthor');
['Joe', 'John', 'Jack', 'Jeremy'].map(async (name) =>
await author.insert({name})
);
['Art', 'Paint'].map(async (title) =>
await book.insert({title})
);
let Author = await author.findOne({ name: 'Joe' });
let Book = await book.findOne({ title: 'Paint' });
await bookauthor.insert({author: Author._id, book: Book._id})
Author = await author.findOne({ name: 'John' });
await bookauthor.insert({author: Author._id, book: Book._id})
Book = await book.findOne({ title: 'Art' });
await bookauthor.insert({author: Author._id, book: Book._id})
const result = await author.aggregate([{
$lookup:{
from: 'bookauthor',
localField: '_id',
foreignField: 'author',
as: 'ba'
}}, {
$lookup: {
from: 'book',
localField: 'ba.book',
foreignField: '_id',
as: 'books'
}}],{
})
});
В отличие от выборки SQL JOIN, полученная выборка будет не плоской таблицей, в которой Автор будет повторяться столько же раз, сколько книг он сочинил, а иерархическим объектом в котором каждый Автор будет представлен одним объектом, у которого будет свойство books, содержащее массив объектов Книга. С моей точки зрения, это очень большой плюс в NoSQL базах данных. Но возможно потребуется и «плоский» вариант, аналогичный SQL JOIN. Для того чтобы его получить в запрос необходимо добавить «разворачивание» массивов: { $unwind: '$books' }
.
Выборка, которая представлена в примере, является аналогом SQL LEFT JOIN, то есть в выборку попадут все авторы, даже если они не имеют книг. Для того, чтобы сделать аналог SQL [INNER] JOIN, необходимо добавить условие { $match: { books: { $ne: [ ] } } }
, или в случае применения $unwind:
{
$unwind: {
path: "$role",
preserveNullAndEmptyArrays: false
}
}
Итак, переходим к arangodb, которая является гибридной базой данных. Помимо работы с документами, в ней реализована работа с графами. Сначала посмотрим как в arangodb можно сделать выборку используя только документы (не графы):
FOR a IN author
FOR ba IN bookauthor
FILTER a._id == ba.author
FOR b IN book
FILTER b._id == ba.book
SORT a.name, b.title
RETURN { author: a, book: b }
Для соединения коллекций arangodb использует ключевое слово FOR, а для задания условия соединения — FILTER. Полученная в данном случае выборка будет аналогична SQL [INNER] JOIN (то есть будет «плоским» объектом и не будет содержать пустых значений)
Но гораздо удобнее в arangodb для выборок из нескольких объектов использовать возможности работы с графами:
const { Database, aql } = require('arangojs');
const db = new Database({
url: "http://localhost:8529"
});
db.useDatabase("test");
db.useBasicAuth("test", "test");
const author = db.collection('author')
const book = db.collection('book')
const bookauthor = db.edgeCollection('bookauthor')
void async function() {
['Joe', 'John', 'Jack', 'Jeremy'].map(async (name) =>
await author.save({name})
);
['Art', 'Paint'].map(async (title) =>
await book.save({title})
);
let Author = await author.firstExample({ name: 'Joe' });
let Book = await book.firstExample({ title: 'Paint' });
await bookauthor.save({date: 'Some data'}, Author._id, Book._id)
Author = await author.firstExample({ name: 'John' });
await bookauthor.save({date: 'Some data'}, Author._id, Book._id)
Book = await book.firstExample({ title: 'Art' });
await bookauthor.save({date: 'Some data'}, Author._id, Book._id)
const cursor = await db.query(aql`
FOR a IN author
FOR book_vertex, book_edge IN OUTBOUND a bookauthor
COLLECT a1 = a INTO b1
RETURN {author: a1, books: b1[*].book_vertex}
`);
}();
У нас для связи теперь служит не документ, а коллекция ребер графа (edge) bookauthor. Оператор IN OUTBOUND a bookauthor
выбирает для заданного Автора a
коллекцию связанных документов, которые помещает в ответ под именем book_vertex
. Оператор COLLECT a1 = a INTO b1
это аналог SQL GROUP — накапливает значение в массив, который в ответе будет доступным под именем b1
для каждого значения Автора а
, которое в ответе будет доступно под именем a1
. Конструкция b1[*].book_vertex
позволяет убрать лишние уровни вложенности из объекта чтобы результат был удобен для дальнейшей работы.
Реализовать запросы типа SQL LEFT JOIN в arangodb сложнее, т.к. оператор FOR — FILTER задает ограничения аналогичные SQL [INNER] JOIN. Для реализации «левых соединений» используется опрератор LET и подзапрос:
const cursor = await db.query(aql`
FOR a IN author
LET books = (
FOR book_vertex, book_edge IN OUTBOUND a bookauthor
RETURN book_vertex
)
RETURN {author: a, books}
`);
В данном случае, группировка данных не требуется, т.к. подзапрос выполняется для каждого Автора и в ответе содержит уже готиовый массив объектов Книга.
Переходим к базе данных orientdb. Это также гибридная база данных, которая позвояет работать как с документами, так и с графами. Идеология работы с графами аналогичная пердыдущему примеру в arangodb. То есть, для связи коллекций служит коллекция ребер графа (edge) bookauthor.
const OrientDB = require('orientjs');
const server = OrientDB({
host: 'localhost',
port: 2424,
});
void async function() {
const db = server.use({
name:'test',
username: 'test',
password: 'test'
});
await db.open();
try {
await db.class.drop('Author UNSAFE');
} catch(ex) {
console.log(ex)
}
try {
await db.class.drop('Book UNSAFE');
} catch(ex) {
console.log(ex)
}
try {
await db.class.drop('BookAuthor UNSAFE');
} catch(ex) {
console.log(ex)
}
const author = await db.class.create('Author', 'V');
const book = await db.class.create('Book', 'V');
const bookauthor = await db.class.create('BookAuthor', 'E');
['Joe', 'John', 'Jack', 'Jeremy'].map(async (name) =>
await author.create({name})
);
['Art', 'Paint'].map(async (title) =>
await book.create({title})
);
await author.list();
await book.list();
let Author = await db.select().from('Author').where({name: 'Joe'}).one();
let Book = await db.select().from('book').where({ title: 'Paint' }).one();
await db.create('EDGE', 'BookAuthor').from(Author['@rid']).to(Book['@rid']).set({date: 'Some data'}).one();
Author = await db.select().from('Author').where({name: 'John'}).one();
await db.create('EDGE', 'BookAuthor').from(Author['@rid']).to(Book['@rid']).set({date: 'Some data'}).one();
Book = await db.select().from('book').where({ title: 'Art' }).one();
await db.create('EDGE', 'BookAuthor').from(Author['@rid']).to(Book['@rid']).set({date: 'Some data'}).one();
const cursor = await db.query(`select name, out('BookAuthor').title as books from Author`).all()
} ()
Пожалуй, в orientdb реализация наиболее удачная, т.к. она ближе всего к синтаксису SQL и лаконична в части работы с графами. Выражение out('BookAuthor').title as books from Author
означает выбрать для коллекции Author все исходящие ребра из коллекции BookAuthor которые связывают коллекцию Author с коллекцией Book. В данном случае полученный объект будет иерархическим (один объект для каждого Автора с массивом объектов Книга). Если нужно «развернуть» массив в плоский объект, применяется оператор UNWIND.
И, напоследок, рассмотрим rethinkdb. Не так давно команда, разрабатывавшая эту базу данных, прекратила свое существование и передала разработку открытому сообществу. Это я говорю сразу, т.к. кто-то мог пропустить эту новость. До более детального знакомства, мне казалась реализация JOIN в rethinkdb наиболее удобной. Возможно, потому что такая возможность сразу была заложена в API базы данных, и даже так и называлась join (). Но потом оказалось, что все не так радужно, и не все функции реализующие JOIN работают одинаково эффективно и имеют достаточную гибкость для построения нужных запросов. Тот же наш сквозной пример теперь реализуем на rethinkdb:
r = require('rethinkdb')
void async function() {
const conn = await r.connect({ host: 'localhost', port: 28015 });
try {
await r.db('test').tableDrop('author').run(conn);
await r.db('test').tableDrop('book').run(conn);
await r.db('test').tableDrop('bookauthor').run(conn);
} catch (ex) {
console.log(ex)
}
await r.db('test').tableCreate('author').run(conn);
await r.db('test').tableCreate('book').run(conn);
await r.db('test').tableCreate('bookauthor').run(conn);
await r.db('test').table('bookauthor').indexCreate('author').run(conn);
await r.db('test').table('bookauthor').indexCreate('book').run(conn);
await r.db('test').table('bookauthor').indexWait('author', 'book').run(conn);
['Joe', 'John', 'Jack', 'Jeremy'].map(async (name) =>
await r.db('test').table('author').insert({ name }).run(conn)
);
['Art', 'Paint'].map(async (title) =>
await r.db('test').table('book').insert({ title }).run(conn)
);
let Author = await r.db('test').table('author').filter({ name: 'Joe' }).run(conn).then(authors => authors.next());
let Book = await r.db('test').table('book').filter({ title: 'Paint' }).run(conn).then(books => books.next());
await r.db('test').table('bookauthor').insert({author: Author.id, book: Book.id}).run(conn);
Author = await r.db('test').table('author').filter({ name: 'John' }).run(conn).then(authors => authors.next());
await r.db('test').table('bookauthor').insert({author: Author.id, book: Book.id}).run(conn);
Book = await r.db('test').table('book').filter({ title: 'Art' }).run(conn).then(books => books.next());
await r.db('test').table('bookauthor').insert({author: Author.id, book: Book.id}).run(conn);
const cursor = await r.db('test').table('author')
.eqJoin('id', r.db('test').table('bookauthor'), {index: 'author'}).zip()
.eqJoin('book', r.db('test').table('book')).zip().run(conn);
}();
Следует обратить внимание на такие моменты. В данном примере была реализована связь по вторичному индексу при помощи функции eqJoin (), которая может при соединении объектов использовать пары: первичный ключ с первичным ключом или первичный ключ с вторичным ключом (но не вторичный ключ с вторичным ключом). Для более сложных условий применяется функция map (), которая на порядок сложнее в понимании. Остальные функции, реализующие JOIN не оптимизированы (надо полагать реализованы полным перебор значений).
Текст примеров расположен в репозитарии.
apapacy@gmail.com
4 июня 2018 года