server/models/base.js

Summary

Maintainability
A
1 hr
Test Coverage
//
//   Copyright 2014-2016 Ilkka Oksanen <iao@iki.fi>
//
//   Licensed under the Apache License, Version 2.0 (the "License");
//   you may not use this file except in compliance with the License.
//   You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
//   Unless required by applicable law or agreed to in writing,
//   software distributed under the License is distributed on an "AS
//   IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
//   express or implied.  See the License for the specific language
//   governing permissions and limitations under the License.
//

import redis from '../lib/redis';

const assert = require('assert');
const util = require('util');
const Rigiddb = require('rigiddb');
const conf = require('../lib/conf');

const db = new Rigiddb(
  'mas',
  1,
  {
    db: 10,
    host: conf.get('redis:host'), // TODO: Add Unix socket support
    port: conf.get('redis:port'),
    retryStrategy: redis.retryStrategy
  },
  redis.errorHandler
);

module.exports = class Base {
  constructor(collection, id = null, initialProps = {}) {
    if (typeof id !== 'number' && id !== null) {
      throw new Error('ID must be a number or null.');
    }

    if (collection === 'models') {
      throw new Error('An abstract Model class cannot be instantiated.');
    }

    this.deleted = false;
    this.collection = collection;
    this.id = id;
    this.errors = {};

    this._props = initialProps;
  }

  static get mutableProperties() {
    return [];
  }

  static get getters() {
    return {};
  }

  static get setters() {
    return {};
  }

  static get config() {
    // Default configuration. Can be overwritten in derived classes.
    return {
      indexErrorDescriptions: {}
    };
  }

  static get collection() {
    return `${this.name[0].toLowerCase()}${this.name.substring(1)}s`;
  }

  get valid() {
    assert(!this.deleted, 'Tried to validate deleted model');
    return Object.keys(this.errors).length === 0;
  }

  static async fetch(id) {
    const record = new this(this.collection, id);
    const { err, val } = await db.get(record.collection, id);

    if (err) {
      return null;
    }

    record._props = val;
    return record;
  }

  static async fetchMany(ids) {
    const res = [];

    for (const id of ids) {
      res.push(await db.get(this.collection, id));
    }

    return res.filter(({ err }) => !err).map(({ val }, index) => new this(this.collection, ids[index], val));
  }

  static async fetchAll() {
    const { err, val } = await db.list(this.collection);

    return err ? [] : this.fetchMany(val);
  }

  static async findIds(props) {
    if (!props || Object.keys(props) === 0) {
      return null;
    }

    const { err, val } = await db.find(this.collection, props);

    assert(!err, `Model findIds failed: ${err}, ${util.inspect(props)}`);

    return val.sort((a, b) => a - b);
  }

  static async find(props, { onlyFirst = false } = {}) {
    const ids = await this.findIds(props);

    if (onlyFirst && ids.length === 0) {
      return null;
    }

    return onlyFirst ? this.fetch(ids[0]) : this.fetchMany(ids);
  }

  static async findFirst(props) {
    return this.find(props, { onlyFirst: true });
  }

  static async create(props, { skipSetters = false } = {}) {
    const record = new this(this.collection);
    let finalProps = props;

    if (!skipSetters) {
      const { errors, preparedProps } = runSetters(props, this.setters);
      finalProps = preparedProps;
      record.errors = errors;
    }

    if (record.valid) {
      const { err, val, indices } = await db.create(record.collection, finalProps);

      if (err === 'notUnique') {
        record.errors = explainIndexErrors(indices, this.config.indexErrorDescriptions);
      } else if (err) {
        throw new Error(`DB ERROR: ${err}, c: ${this.collection}, p: ${JSON.stringify(props)}`);
      } else {
        record.id = val || null;
        record._props = finalProps;
      }
    }

    return record;
  }

  static async currentId() {
    const { err, val } = await db.currentId(this.collection);

    assert(!err, 'Failed to read currentId');

    return val;
  }

  get(prop) {
    assert(!this.deleted, `Tried to read property ${prop} from deleted model`);

    const rawVal = this._props[prop];

    assert(typeof rawVal !== 'undefined', `Tried to read non-existenting property ${prop}`);

    return Base.getters[prop] ? Base.getters[prop](rawVal) : rawVal;
  }

  getAll() {
    assert(!this.deleted, 'Tried to read deleted model');

    const props = {};

    Object.keys(this._props).forEach(prop => {
      const rawVal = this._props[prop];
      props[prop] = Base.getters[prop] ? Base.getters[prop](rawVal) : rawVal;
    });

    return props;
  }

  async set(props, value) {
    const objectProps = convertToObject(props, value);

    const allowed = Object.keys(objectProps).every(prop => this.constructor.mutableProperties.includes(prop));

    if (!allowed) {
      throw new Error('Tried to set non-existent or protected property');
    }

    return this._set(objectProps);
  }

  async _set(objectProps) {
    assert(!this.deleted, 'Tried to mutate deleted model');

    const { errors, preparedProps } = runSetters(objectProps, Base.setters);

    if (!preparedProps) {
      Object.keys(objectProps).forEach(prop => {
        if (errors[prop]) {
          this.errors[prop] = errors[prop];
        } else {
          delete this.errors[prop];
        }
      });

      return false; // One or more setters failed
    }

    const { err, indices, val } = await db.update(this.collection, this.id, preparedProps);

    if (err === 'notUnique') {
      this.errors = explainIndexErrors(indices, this.constructor.config.indexErrorDescriptions);
    } else if (err) {
      throw new Error(`DB ERROR: ${err}, c: ${this.collection}, p: ${JSON.stringify(objectProps)}`);
    } else {
      this.errors = {};
      Object.assign(this._props, objectProps);
    }

    return val;
  }

  async delete() {
    assert(!this.deleted, 'Tried to delete deleted model');

    const { val } = await db.delete(this.collection, this.id);
    this.deleted = true;

    return val;
  }
};

function runSetters(objectProps, setters) {
  const preparedProps = {};
  const errors = {};

  for (const prop of Object.keys(objectProps)) {
    const value = objectProps[prop];

    if (setters[prop]) {
      const { valid, value: rawValue, error } = setters[prop](value);

      if (!valid) {
        errors[prop] = error;
      }

      preparedProps[prop] = valid ? rawValue : null;
    } else {
      preparedProps[prop] = value;
    }
  }

  return { errors, preparedProps };
}

function convertToObject(props, value) {
  if (!props) {
    return {};
  }

  return typeof props === 'string' ? { [props]: value } : props;
}

function explainIndexErrors(indices, descriptions = {}) {
  const errors = {};

  indices.forEach(index => {
    errors[index] = descriptions[index] || 'INDEX ERROR: Value already exists.';
  });

  return errors;
}