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 года

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

      +1
      Важно понимать, что $lookup в mongo — это не замена join из реляционных баз, а этап аггрегирущего запроса со всеми вытекающими: не работает в кластере, индексы могут быть не доступны, в зависимости от этапа пайплайна, и так далее.
        0
        Спасибо за уточнение. О том как ведут эти операторы на кластере я не подумал. Нужно будет отдельно исследовать эту задачу. По поводу конкретно $lookup в mongo — скорее всего не работат на шарде, так во всяком случае написано в документации (в смысле что на кластере не все коллекции обязательно будут шардированы и по идее $lookup в mongo должен в этом случае работать).
          +1
          Да-да, а еще нужно помнить о 16 мегабайтном ограничении на BSON документ, распространяющийся на каждый этап аггрегации.
          +1
          Изобрели nested loops? Это практически всегда самый неоптимальный вариант для join :)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое