Skip to content

Instantly share code, notes, and snippets.

@Brian-McBride
Last active February 15, 2020 18:53
Show Gist options
  • Save Brian-McBride/a7b45bf0f3cb955b49b097332f78ba5c to your computer and use it in GitHub Desktop.
Save Brian-McBride/a7b45bf0f3cb955b49b097332f78ba5c to your computer and use it in GitHub Desktop.
Feathers-ArangoDB

This is a start to get ArangoDB as a connector for FeathersJS

Dependencies

You'll need a few npm packages.

yarn add arangojs
yarn add uuid
yarn add clone-deep

src/connectors/feathers-arangodb.js

First, we will create a feathers-arangodb.js file in the /src/connectors directory.

const Proto = require('uberproto');
const errors = require('@feathersjs/errors');
const cloneDeep = require('clone-deep');
const aql = require('arangojs').aql;
const uuidV4 = require('uuid/v4');

const { sorter, select, filterQuery, _ } = require('@feathersjs/commons');
// var _feathersCommons = require('feathers-commons');

const sift = require('sift');

const _select = (...args) => {
  const base = select(...args);

  return function (result) {
    return base(cloneDeep(result));
  };
};

class Service {
  constructor (options = {}) {
    this.paginate = options.paginate || {};
    this._id = this.id = options.idField || options.id || '_key';
    this._uId = options.startId || 0;
    this.store = options.store || {};
    this.events = options.events || [];
    this._matcher = options.matcher;
    this._sorter = options.sorter || sorter;
    this._colName = options.collection;
    this._db = options.db;
  }

  extend (obj) {
    return Proto.extend(obj, this);
  }

  init (db = this._db, name = this._colName) {
    this.db = db;
    this._colName = name;
    this._collection = db.collection(name);

    return new Promise((resolve, reject) => {
      this._collection.get()
        .then(() => {
          resolve(db);
        })
        .catch(err => {
          if (err.errorNum === 1203 || err.message === 'collection not found') {
            this._collection.create()
              .then(() => {
                resolve(db);
              })
              .catch(errCol => {
                reject(errCol);
              });
          } else {
            reject(err);
          }
        });
    });


  }

  // Find without hooks and mixins that can be used internally and always returns
  // a pagination object
  _find (params, getFilter = filterQuery) {
    const { query, filters } = getFilter(params.query || {});
    const map = _select(params);
    let values = _.values(this.store); // Won't be used....

    // In-memory query
    if (this._matcher) {
      values = values.filter(this._matcher(query));
    } else {
      values = sift(query, values);
    }

    // Count of in-memory query result.
    const total = values.length;

    // Sort order
    if (filters.$sort) {
      values.sort(this._sorter(filters.$sort));
    }

    // Skip the results by slicing array
    if (filters.$skip) {
      values = values.slice(filters.$skip);
    }

    // limits by slicing off the end of the array
    if (typeof filters.$limit !== 'undefined') {
      values = values.slice(0, filters.$limit);
    }

    // Returning everything back...
    return Promise.resolve({
      total,
      limit: filters.$limit,
      skip: filters.$skip || 0,
      data: map(values)
    });
  }

  find (params) {
    const paginate = typeof params.paginate !== 'undefined' ? params.paginate : this.paginate;
    // Call the internal find with query parameter that include pagination
    const result = this._find(params, query => filterQuery(query, paginate));

    if (!(paginate && paginate.default)) {
      return result.then(page => page.data);
    }

    return result;
  }

  get (id, params) {
    const identifier = this._colName + '/' + id;
    return this.db.query(aql`
      RETURN DOCUMENT(${identifier})
    `).then(cursor => cursor.next().then(doc => select(params, this.id)(cloneDeep(doc))));
  }

  // Create without hooks and mixins that can be used internally
  _create (data, params) {
    let id = data[this._id] || uuidV4();
    let current = _.extend({}, _.omit(data, '_id', '_rev'), { [this._id]: id });

    return this.db.query(aql`
      INSERT ${current}
      IN ${this._collection}
      RETURN NEW
    `).then(cursor => cursor.next().then(doc => select(params, this.id)(cloneDeep(doc))));
  }

  create (data, params) {
    if (Array.isArray(data)) {
      return Promise.all(data.map(current => this._create(current)));
    }

    return this._create(data, params);
  }

  // Update without hooks and mixins that can be used internally
  _update (id, data, params) {
    const identifier = this._colName + '/' + id;
    const current = _.omit(data, this._id, '_id', '_key', '_rev');
    return this.db.query(aql`
      LET doc = DOCUMENT(${identifier})
      REPLACE doc WITH ${current}
      IN ${this._collection}
      RETURN NEW
    `)
      .then(cursor => cursor.next().then(doc => select(params, this.id)(cloneDeep(doc))))
      .catch(err => {
        if (err.errorNum === 1226 || err.errorNum === 1227) return Promise.reject(new errors.NotFound(`No record found for id '${id}'`))
        return Promise.reject(err);
      });
  }

  update (id, data, params) {
    if (id === null || Array.isArray(data)) {
      return Promise.reject(new errors.BadRequest(
        'You can not replace multiple instances. Did you mean \'patch\'?'
      ));
    }

    return this._update(id, data, params);
  }

  // Patch without hooks and mixins that can be used internally
  _patch (id, data, params) {
    const identifier = this._colName + '/' + id;
    const current = _.omit(data, this._id, '_id', '_key', '_rev');
    return this.db.query(aql`
      LET doc = DOCUMENT(${identifier})
      UPDATE doc WITH ${current}
      IN ${this._collection}
      RETURN NEW
    `)
      .then(cursor => cursor.next().then(doc => select(params, this.id)(cloneDeep(doc))))
      .catch(err => {
        if (err.errorNum === 1226 || err.errorNum === 1227) return Promise.reject(new errors.NotFound(`No record found for id '${id}'`))
        return Promise.reject(err);
      });
  }

  patch (id, data, params) {
    if (id === null) {
      return this._find(params).then(page => {
        return Promise.all(page.data.map(
          current => this._patch(current[this._id], data, params))
        );
      });
    }

    return this._patch(id, data, params);
  }

  // Remove without hooks and mixins that can be used internally
  _remove (id, params) {
    const identifier = this._colName + '/' + id;
    return this.db.query(aql`
      LET doc = DOCUMENT(${identifier})
      REMOVE doc
      IN ${this._collection}
      RETURN OLD
    `)
      .then(cursor => cursor.next().then(doc => select(params, this.id)(cloneDeep(doc))))
      .catch(err => {
        if (err.errorNum === 1226 || err.errorNum === 1227) return Promise.reject(new errors.NotFound(`No record found for id '${id}'`))
        return Promise.reject(err);
      });
  }

  remove (id, params) {
    if (id === null) {
      return this._find(params).then(page =>
        Promise.all(page.data.map(current =>
          this._remove(current[this._id], params
          )
        )));
    }

    return this._remove(id, params);
  }
}

module.exports = function init (options) {
  return new Service(options);
};

module.exports.Service = Service;

Please note that the _find method is currently a copy of the memory connector. This will NOT work as is.

Modify config/default.json

This is assuming you use the feathers-cli to set up a default project. Alternatively, you could modify the next file to pull in your settings or hard-code them. Following the CLI pattern, add this to your default.json file in the /config directory:

  "arangodb": {
    "host": "http://localhost",
    "port": "8529",
    "database": "feathers",
    "basicAuth": {
      "username": "yourUserName",
      "password": "yourPassword"
    }
  },

src/arangodb.js

Now create a arangodb.js file in the /src directory.

const Database = require('arangojs').Database;

module.exports = function () {
  const app = this;
  const config = app.get('arangodb');
  const promise = dbConnect(config);

  app.set('arangoClient', promise);
};

function dbConnect (options = {}) {
  const host = options.host || process.env.ARANGODB_HOST;
  const port = options.port || process.env.ARANGODB_PORT;
  const databaseName = options.database || process.env.ARANGODB_DB;

  const basicAuth = options.basicAuth || {};
  const username = basicAuth.username || process.env.ARANGODB_USERNAME;
  const password = basicAuth.password || process.env.ARANGODB_PASSWORD;

  const url = `${host}:${port}`;

  const db = new Database(url);
  db.useDatabase(databaseName);
  if (username) db.useBasicAuth(username, password);

  return new Promise((resolve, reject) => {
    db.get()
      .then(() => resolve(db))
      .catch(err => reject(err));
  });
}

Modify app.js

You will need to add these lines into app.js in the appropriate spots:

const arangodb = require('./arangodb');
app.configure(arangodb);

Define a service

Now you can create a service using the arango connector

const createService = require('../../connectors/feathers-arangodb');
const hooks = require('./someservice.hooks');

module.exports = function (app) {
  const paginate = app.get('paginate');
  const arangoClient = app.get('arangoClient');

  const options = {
    paginate
  };

  // Initialize our service with any options it requires
  app.use('/someservice', createService(options));

  // Get our initialized service so that we can register hooks and filters
  const service = app.service('someservice');

  arangoClient.then(db => {
    service.init(db, 'someservice');
  });

  service.hooks(hooks);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment