Home Reference Source

packages/skygear-core/lib/database.js

/**
 * Copyright 2015 Oursky Ltd.
 *
 * 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 _ from 'lodash';
import xmlParser from 'fast-xml-parser';

import Cache from './cache';
import Asset, {isAsset} from './asset';
import {isRecord} from './record';
import Query from './query';
import QueryResult from './query_result';
import {isValueType} from './util';
import { ErrorCodes, SkygearError } from './error';

export class Database {

  /**
   * Creates a Skygear database.
   *
   * @param  {String} dbID - database ID
   * @param  {Container} container - Skygear Container
   * @return {Database}
   */
  constructor(dbID, container) {
    if (dbID !== '_public' && dbID !== '_private' && dbID !== '_union') {
      throw new Error('Invalid database_id');
    }

    /**
     * The database ID
     * @type {String}
     */
    this.dbID = dbID;

    /**
     * @private
     */
    this.container = container;
    this._cacheStore = new Cache(this.dbID, this.container.store);
    this._cacheResponse = true;
  }


  /**
   * Fetches a single record with the specified id from Skygear.
   *
   * Use this method to fetch a single record from Skygear by specifying a
   * record ID in the format of `type/id`. The fetch will be performed
   * asynchronously and the returned promise will be resolved when the
   * operation completes.
   *
   * @param  {String} id - record ID with format `type/id`
   * @return {Promise<Record>} promise with the fetched Record
   */
  async getRecordByID(id) {
    let Record = this._Record;
    let [recordType, recordId] = Record.parseID(id);
    let query = new Query(Record.extend(recordType)).equalTo('_id', recordId);
    const users = await this.query(query);
    if (users.length === 1) {
      return users[0];
    } else {
      throw new Error(id + ' does not exist');
    }
  }

  /**
   * Fetches records that match the Query from Skygear.
   *
   * Use this method to fetch records from Skygear by specifying a Query.
   * The fetch will be performed asynchronously and the returned promise will
   * be resolved when the operation completes.
   *
   * If cacheCallback is provided, the SDK would try to fetch result of the
   * query from local cache, before issuing query request to the server, and
   * trigger the cacheCallback function if cached result is found.
   *
   * @param  {Query} query
   * @param  {function(queryResult:QueryResult,isCached:boolean)} cacheCallback
   * @return {Promise<QueryResult>} promise with the QueryResult
   */
  async query(query, cacheCallback = false) {
    let remoteReturned = false;
    let cacheStore = this.cacheStore;
    let Cls = query.recordCls;
    let queryJSON = query.toJSON();

    if (!queryJSON.offset && queryJSON.page > 0) {
      queryJSON.offset = queryJSON.limit * (queryJSON.page - 1);
    }

    let payload = _.assign({
      database_id: this.dbID //eslint-disable-line
    }, queryJSON);

    if (cacheCallback) {
      (async () => {
        try {
          const body = await cacheStore.get(query.hash);
          if (remoteReturned) {
            return;
          }
          let records = _.map(body.result, function (attrs) {
            return new Cls(attrs);
          });
          let result = QueryResult.createFromResult(records, body.info);
          cacheCallback(result, true);
        } catch (err) {
          console.log('No cache found', err);
        }
      })();
    }

    const body = await this.container.makeRequest('record:query', payload);
    let records = _.map(body.result, function (attrs) {
      return new Cls(attrs);
    });
    let result = QueryResult.createFromResult(records, body.info);
    remoteReturned = true;
    if (this.cacheResponse) {
      await cacheStore.set(query.hash, body);
    }
    return result;
  }

  /**
   * Presave a single value.
   *
   * A single value is the smallest unit of object to presave. In other words,
   * a single value does not contain smaller values to presave.
   *
   * This function returns a promise that may perform other operations, such
   * as calling the server to upload asset.
   */
  async _presaveSingleValue(value) {
    if (isAsset(value) && value.file) {
      return this.uploadAsset(value);
    } else {
      return value;
    }
  }

  /**
   * Presave a value as part of a key-value object.
   *
   * This function differs from _presave in that it returns the key of the
   * parent object that is being presaved. This helps constructs the object
   * after resolving all promises.
   */
  async _presaveKeyValue(fn, key, value) {
    const v = await this._presave(fn, value);
    return [key, v];
  }

  /**
   * Presave a value.
   *
   * If the value contains other objects that can be presaved, it will
   * iterates each member and create a promise for such object. Essentially this
   * function creates a tree of promises that resembles the object tree.
   */
  async _presave(fn, value) {
    if (value === undefined || value === null) {
      return value;
    } else if (_.isArray(value)) {
      return Promise.all(_.map(value, this._presave.bind(this, fn)));
    } else if (isRecord(value)) {
      const record = value;
      let tasks = _.map(record, (v, k) => {
        return this._presaveKeyValue(fn, k, v);
      });
      const keyvalues = await Promise.all(tasks);
      _.each(keyvalues, ([k, v]) => {
        record[k] = v;
      });
      return record;
    } else if (isValueType(value) || !_.isObject(value)) {
      // The value does not contain other objects that can be presaved.
      // Call _presaveSingleValue to create the actual promise which performs
      // other operations, such as upload asset.
      return fn(value);
    } else {
      const obj = value;
      let tasks = _.chain(obj)
        .keys()
        .map((key) => this._presaveKeyValue(fn, key, obj[key]))
        .value();
      const keyvalues = await Promise.all(tasks);
      _.each(keyvalues, ([k, v]) => {
        obj[k] = v;
      });
      return obj;
    }
  }

  /**
   * Same as {@link Database#delete}.
   *
   * @param  {Record|Record[]|QueryResult} record - the record(s) to delete
   * @return {Promise<Record>} promise with the delete result
   * @see {@link Database#delete}
   */
  async del(record) {
    return this.delete(record);
  }

  /**
   * Saves a record or records to Skygear.
   *
   * Use this method to save a record or records to Skygear.
   * The save will be performed asynchronously and the returned promise will
   * be resolved when the operation completes.
   *
   * New record will be created in the database while existing
   * record will be modified.
   *
   * options.atomic can be set to true, which makes the operation either
   * success or failure, but not partially success.
   *
   * @param {Record|Record[]} _records - the record(s) to save
   * @param {Object} [options={}] options - options for saving the records
   * @param {Boolean} [options.atomic] - true if the save request should be
   * atomic
   * @return {Promise<Record>} promise with saved records
   */
  async save(_records, options = {}) {
    let records = _records;
    if (!_.isArray(records)) {
      records = [records];
    }

    if (_.some(records, r => r === undefined || r === null)) {
      throw new Error('Invalid input, unable to save undefined and null');
    }

    const processedRecords = await this._presave(
      this._presaveSingleValue.bind(this),
      records
    );

    let payload = {
      database_id: this.dbID //eslint-disable-line
    };

    if (options.atomic) {
      payload.atomic = true;
    }

    payload.records = _.map(processedRecords, (perRecord) => {
      return perRecord.toTruncatedJSON();
    });

    const body = await this.container.makeRequest('record:save', payload);

    let results = body.result;
    let savedRecords = [];
    let errors = [];

    _.forEach(results, (perResult, idx) => {
      if (perResult._type === 'error') {
        savedRecords[idx] = undefined;
        errors[idx] = perResult;
      } else {
        records[idx].update(perResult);
        records[idx].updateTransient(perResult._transient, true);

        savedRecords[idx] = records[idx];
        errors[idx] = undefined;
      }
    });

    if (records.length === 1) {
      if (errors[0]) {
        throw errors[0];
      }
      return savedRecords[0];
    }
    return {savedRecords, errors};
  }

  /**
   * Deletes a record or records to Skygear.
   *
   * Use this method to delete a record or records to Skygear.
   * The delete will be performed asynchronously and the returned promise will
   * be resolved when the operation completes.
   *
   * @param  {Record|Record[]|QueryResult} _records - the records to delete
   * @return {Promise} promise
   */
  async delete(_records) {
    let records = _records;
    let isQueryResult = records instanceof QueryResult;
    if (!_.isArray(records) && !isQueryResult) {
      records = [records];
    }

    let ids = _.map(records, perRecord => perRecord.id);
    let payload = {
      database_id: this.dbID, //eslint-disable-line
      ids: ids
    };

    const body = await this.container.makeRequest('record:delete', payload);
    let results = body.result;
    let errors = [];

    _.forEach(results, (perResult, idx) => {
      if (perResult._type === 'error') {
        errors[idx] = perResult;
      } else {
        errors[idx] = undefined;
      }
    });

    if (records.length === 1) {
      if (errors[0]) {
        throw errors[0];
      }
      return;
    }
    return errors;
  }

  /**
   * @type {Store}
   */
  get cacheStore() {
    return this._cacheStore;
  }

  /**
   * Indicating if query result should be cached locally
   *
   * @type {boolean}
   */
  get cacheResponse() {
    return this._cacheResponse;
  }

  /**
   * Indicating if query result should be cached locally
   *
   * @type {boolean}
   */
  set cacheResponse(value) {
    const b = !!value;
    this._cacheResponse = b;
  }

  get _Record() {
    return this.container.Record;
  }

  /**
   * Uploads asset to Skygear server.
   *
   * @param  {Asset} asset - the asset
   * @return {Promise<Asset>} promise
   */
  uploadAsset(asset) {
    return makeUploadAssetRequest(this.container, asset);
  }
}

export class PublicDatabase extends Database {

  /**
   * The default ACL of a newly created record
   *
   * @type {ACL}
   */
  get defaultACL() {
    return this._Record.defaultACL;
  }

  /**
   * Sets default ACL of a newly created record.
   *
   * @param {ACL} acl - the default acl
   */
  setDefaultACL(acl) {
    this._Record.defaultACL = acl;
  }

  /**
   * Sets the roles that are allowed to create records of a record type.
   *
   * @param {Class} recordClass - the record class created with
   * {@link Record.extend}
   * @param {Role[]} roles - the roles
   * @return {Promise} promise
   */
  async setRecordCreateAccess(recordClass, roles) {
    let roleNames = _.map(roles, function (perRole) {
      return perRole.name;
    });

    const body = await this.container.makeRequest('schema:access', {
      type: recordClass.recordType,
      create_roles: roleNames //eslint-disable-line camelcase
    });
    return body.result;
  }

  /**
   * Sets the default ACL of a newly created record of a record type.
   *
   * @param {Class} recordClass - the record class created with
   * {@link Record.extend}
   * @param {ACL} acl - the default acl
   * @return {Promise} promise
   */
  async setRecordDefaultAccess(recordClass, acl) {
    const body = await this.container.makeRequest('schema:default_access', {
      type: recordClass.recordType,
      default_access: acl.toJSON() //eslint-disable-line camelcase
    });
    return body.result;
  }

}

/**
 * @private
 */
export class DatabaseContainer {

  /**
   * Creates a DatabaseContainer.
   *
   * @param  {Container} container - the Skygear container
   */
  constructor(container) {
    /**
     * @private
     */
    this.container = container;

    this._public = null;
    this._private = null;
    this._cacheResponse = true;
  }

  /**
   * @type {PublicDatabase}
   */
  get public() {
    if (this._public === null) {
      this._public = new PublicDatabase('_public', this.container);
      this._public.cacheResponse = this._cacheResponse;
    }
    return this._public;
  }

  /**
   * Private database of the current user
   *
   * @type {Database}
   */
  get private() {
    if (this.container.accessToken === null) {
      throw new Error('You must login before access to privateDB');
    }
    if (this._private === null) {
      this._private = new Database('_private', this.container);
      this._private.cacheResponse = this._cacheResponse;
    }
    return this._private;
  }

  /**
   * Uploads asset to Skygear server.
   *
   * @deprecated use Database.uploadAsset instead.
   *
   * @param  {Asset} asset - the asset
   * @return {Promise<Asset>} promise
   */
  async uploadAsset(asset) {
    return this.public.uploadAsset(asset);
  }

  /**
   * True if the database cache result from response
   *
   * @type {Boolean}
   */
  get cacheResponse() {
    return this._cacheResponse;
  }

  /**
   * True if the database cache result from response
   *
   * @type {Boolean}
   */
  set cacheResponse(value) {
    const b = !!value;
    this._cacheResponse = b;
    if (this._public) {
      this._public.cacheResponse = b;
    }
    if (this._private) {
      this._private.cacheResponse = b;
    }
  }

}

async function makeUploadAssetRequest(container, asset) {
  const res = await container.makeRequest('asset:put', {
    filename: asset.name,
    'content-type': asset.contentType,
    // asset.file.size for File and Blob
    // asset.file.byteLength for Buffer
    'content-size': asset.file.size || asset.file.byteLength
  });

  const newAsset = Asset.fromJSON(res.result.asset);
  const postRequest = res.result['post-request'];

  let postUrl = postRequest.action;
  if (postUrl.indexOf('/') === 0) {
    postUrl = postUrl.substring(1);
  }
  if (postUrl.indexOf('http') !== 0) {
    postUrl = container.url + postUrl;
  }

  await new Promise((resolve, reject) => {
    let _request = container.request
      .post(postUrl)
      .set('X-Skygear-API-Key', container.apiKey);
    if (postRequest['extra-fields']) {
      _.forEach(postRequest['extra-fields'], (value, key) => {
        _request = _request.field(key, value);
      });
    }

    if (asset.file instanceof Buffer) {
      // need providing file name to buffer
      _request = _request.attach('file', asset.file, asset.name);
    } else {
      _request = _request.attach('file', asset.file);
    }

    _request.end(async (err) => {
      if (err) {
        try {
          const [code, message] = await parseS3XmlErrorMessage(err);
          reject(_s3ErrorToSkyError(code, message));
        } catch (_err) {
          reject(err);
        }

        return;
      }

      resolve();
    });
  });
  return newAsset;
}


/**
 * Best effort to parse s3 error code and message.
 * If success, resolve with [code, message].
 */
async function parseS3XmlErrorMessage(error) {
  if (!error.response || !error.response.text) {
    throw new Error('Unable to get response text');
  }

  const result = xmlParser.parse(error.response.text);
  if (result.Error &&
      result.Error.Code && typeof result.Error.Code === 'string' &&
      result.Error.Message && typeof result.Error.Message === 'string') {
    return [result.Error.Code, result.Error.Message];
  }

  throw new Error('Malformed S3 response error');
}

function _s3ErrorToSkyError(code, message) {
  const codeMap = {
    EntityTooLarge: ErrorCodes.AssetSizeTooLarge
  };

  return new SkygearError(message, codeMap[code] || ErrorCodes.UnexpectedError);
}