В этом сообщении будут рассмотрены способы соединения коллекций в 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 года

Комментарии (4)


  1. untilx
    04.06.2018 11:30
    +1

    Важно понимать, что $lookup в mongo — это не замена join из реляционных баз, а этап аггрегирущего запроса со всеми вытекающими: не работает в кластере, индексы могут быть не доступны, в зависимости от этапа пайплайна, и так далее.


    1. apapacy Автор
      04.06.2018 13:26

      Спасибо за уточнение. О том как ведут эти операторы на кластере я не подумал. Нужно будет отдельно исследовать эту задачу. По поводу конкретно $lookup в mongo — скорее всего не работат на шарде, так во всяком случае написано в документации (в смысле что на кластере не все коллекции обязательно будут шардированы и по идее $lookup в mongo должен в этом случае работать).


    1. rayz_razko
      04.06.2018 22:59
      +1

      Да-да, а еще нужно помнить о 16 мегабайтном ограничении на BSON документ, распространяющийся на каждый этап аггрегации.


  1. Fragster
    04.06.2018 13:14

    Изобрели nested loops? Это практически всегда самый неоптимальный вариант для join :)