В этом сообщении будут рассмотрены способы соединения коллекций в NoSQL базах данных mongodb, arangodb, orientdb и rethinkdb (помимо того, что это NoSQL базы данных, их объединяет еще и наличие бесплатной версии с достаточно лояльной лицензией). В реляционных базах данных аналогичная функциональность реализуется при помощи SQL JOIN. Несмотря на то, что CRUD — операции в NoSQL базах данных очень похожи и различаются только в деталях, например, в одной базе данных для создания объекта используется функция create({… }), в другой — insert({… }), а в третьей — save({… }), — реализация выборки из двух и более коллекций в каждой из баз данных реализована совершенно по-разному. Поэтому будет интересно выполнить на всех базах данных одинаковую выборку. Для всех баз будет рассмотрено получение выборки (связь типа многие-ко многим) для двух таблиц.
Для примера будут использованы таблицы
Автор
Книга
КнигиАвторов
Для mongodb выборка из таблиц будет реализована так:
В отличие от выборки SQL JOIN, полученная выборка будет не плоской таблицей, в которой Автор будет повторяться столько же раз, сколько книг он сочинил, а иерархическим объектом в котором каждый Автор будет представлен одним объектом, у которого будет свойство books, содержащее массив объектов Книга. С моей точки зрения, это очень большой плюс в NoSQL базах данных. Но возможно потребуется и «плоский» вариант, аналогичный SQL JOIN. Для того чтобы его получить в запрос необходимо добавить «разворачивание» массивов:
Выборка, которая представлена в примере, является аналогом SQL LEFT JOIN, то есть в выборку попадут все авторы, даже если они не имеют книг. Для того, чтобы сделать аналог SQL [INNER] JOIN, необходимо добавить условие
Итак, переходим к arangodb, которая является гибридной базой данных. Помимо работы с документами, в ней реализована работа с графами. Сначала посмотрим как в arangodb можно сделать выборку используя только документы (не графы):
Для соединения коллекций arangodb использует ключевое слово FOR, а для задания условия соединения — FILTER. Полученная в данном случае выборка будет аналогична SQL [INNER] JOIN (то есть будет «плоским» объектом и не будет содержать пустых значений)
Но гораздо удобнее в arangodb для выборок из нескольких объектов использовать возможности работы с графами:
У нас для связи теперь служит не документ, а коллекция ребер графа (edge) bookauthor. Оператор
Реализовать запросы типа SQL LEFT JOIN в arangodb сложнее, т.к. оператор FOR — FILTER задает ограничения аналогичные SQL [INNER] JOIN. Для реализации «левых соединений» используется опрератор LET и подзапрос:
В данном случае, группировка данных не требуется, т.к. подзапрос выполняется для каждого Автора и в ответе содержит уже готиовый массив объектов Книга.
Переходим к базе данных orientdb. Это также гибридная база данных, которая позвояет работать как с документами, так и с графами. Идеология работы с графами аналогичная пердыдущему примеру в arangodb. То есть, для связи коллекций служит коллекция ребер графа (edge) bookauthor.
Пожалуй, в orientdb реализация наиболее удачная, т.к. она ближе всего к синтаксису SQL и лаконична в части работы с графами. Выражение
И, напоследок, рассмотрим rethinkdb. Не так давно команда, разрабатывавшая эту базу данных, прекратила свое существование и передала разработку открытому сообществу. Это я говорю сразу, т.к. кто-то мог пропустить эту новость. До более детального знакомства, мне казалась реализация JOIN в rethinkdb наиболее удобной. Возможно, потому что такая возможность сразу была заложена в API базы данных, и даже так и называлась join(). Но потом оказалось, что все не так радужно, и не все функции реализующие JOIN работают одинаково эффективно и имеют достаточную гибкость для построения нужных запросов. Тот же наш сквозной пример теперь реализуем на rethinkdb:
Следует обратить внимание на такие моменты. В данном примере была реализована связь по вторичному индексу при помощи функции eqJoin(), которая может при соединении объектов использовать пары: первичный ключ с первичным ключом или первичный ключ с вторичным ключом (но не вторичный ключ с вторичным ключом). Для более сложных условий применяется функция map(), которая на порядок сложнее в понимании. Остальные функции, реализующие JOIN не оптимизированы (надо полагать реализованы полным перебор значений).
Текст примеров расположен в репозитарии.
apapacy@gmail.com
4 июня 2018 года
Для примера будут использованы таблицы
Автор
- имя
Книга
- Название
КнигиАвторов
- Автор
- Книга
Для 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 года
