// Copyright 2016, Google, Inc.
//
// 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.
'use strict';
import { utils } from 'js-data';
import Datastore from '@google-cloud/datastore';
import {
Adapter,
reserved
} from 'js-data-adapter';
const DATASTORE_DEFAULTS = {
projectId: process.env.GCLOUD_PROJECT
};
const equal = function (query, field, value) {
return query.filter(field, '=', value);
};
/**
* Default predicate functions for the filtering operators.
*
* @name module:js-data-cloud-datastore.OPERATORS
* @property {function} == Equality operator.
* @property {function} > "Greater than" operator.
* @property {function} >= "Greater than or equal to" operator.
* @property {function} < "Less than" operator.
* @property {function} <= "Less than or equal to" operator.
*/
export const OPERATORS = {
'==': equal,
'===': equal,
'>': function (query, field, value) {
return query.filter(field, '>', value);
},
'>=': function (query, field, value) {
return query.filter(field, '>=', value);
},
'<': function (query, field, value) {
return query.filter(field, '<', value);
},
'<=': function (query, field, value) {
return query.filter(field, '<=', value);
}
};
/**
* CloudDatastoreAdapter class.
*
* @example
* // Use Container instead of DataStore on the server
* import {Container} from 'js-data'
* import {CloudDatastoreAdapter} from 'js-data-cloud-datastore'
*
* // Create a store to hold your Mappers
* const store = new Container()
*
* // Create an instance of CloudDatastoreAdapter with default settings
* const adapter = new CloudDatastoreAdapter()
*
* // Mappers in "store" will use the CloudDatastore adapter by default
* store.registerAdapter('datastore', adapter, { default: true })
*
* // Create a Mapper that maps to a "user" table
* store.defineMapper('user')
*
* @class CloudDatastoreAdapter
* @extends Adapter
* @param {object} [opts] Configuration options.
* @param {boolean} [opts.debug=false] See {@link Adapter#debug}.
* @param {function} [opts.datastore] See {@link CloudDatastoreAdapter#datastore}.
* @param {object} [opts.datastoreOpts] See {@link CloudDatastoreAdapter#datastoreOpts}.
* Ignored if you provide a pre-configured datastore instance.
* @param {boolean} [opts.raw=false] See {@link Adapter#raw}.
*/
export function CloudDatastoreAdapter (opts) {
utils.classCallCheck(this, CloudDatastoreAdapter);
opts || (opts = {});
// Setup non-enumerable properties
Object.defineProperties(this, {
/**
* Instance of Datastore used by this adapter. Use this directly when
* you need to write custom queries.
*
* @name CloudDatastoreAdapter#datastore
* @type {object}
*/
datastore: {
writable: true,
value: undefined
}
});
Adapter.call(this, opts);
/**
* Options to be passed to a new Datastore instance, if one wasn't provided.
*
* @name CloudDatastoreAdapter#datastoreOpts
* @type {object}
* @default {}
* @property {string} projectId Google Cloud Platform project id.
*/
this.datastoreOpts || (this.datastoreOpts = {});
utils.fillIn(this.datastoreOpts, DATASTORE_DEFAULTS);
/**
* Override the default predicate functions for the specified operators.
*
* @name CloudDatastoreAdapter#operators
* @type {object}
* @default {}
*/
this.operators || (this.operators = {});
utils.fillIn(this.operators, OPERATORS);
this.datastore || (this.datastore = Datastore(this.datastoreOpts));
}
Adapter.extend({
constructor: CloudDatastoreAdapter,
/**
* Apply the specified selection query to the provided Datastore query.
*
* @method CloudDatastoreAdapter#filterQuery
* @param {object} mapper The mapper.
* @param {object} [query] Selection query.
* @param {object} [query.where] Filtering criteria.
* @param {string|Array} [query.orderBy] Sorting criteria.
* @param {string|Array} [query.sort] Same as `query.sort`.
* @param {number} [query.limit] Limit results.
* @param {number} [query.skip] Offset results.
* @param {number} [query.offset] Same as `query.skip`.
* @param {object} [opts] Configuration options.
* @param {object} [opts.operators] Override the default predicate functions
* for specified operators.
*/
filterQuery (dsQuery, query, opts) {
query = utils.plainCopy(query || {});
opts || (opts = {});
opts.operators || (opts.operators = {});
query.where || (query.where = {});
query.orderBy || (query.orderBy = query.sort);
query.orderBy || (query.orderBy = []);
query.skip || (query.skip = query.offset);
// Transform non-keyword properties to "where" clause configuration
utils.forOwn(query, (config, keyword) => {
if (reserved.indexOf(keyword) === -1) {
if (utils.isObject(config)) {
query.where[keyword] = config;
} else {
query.where[keyword] = {
'==': config
};
}
delete query[keyword];
}
});
// Apply filter
if (Object.keys(query.where).length !== 0) {
utils.forOwn(query.where, (criteria, field) => {
if (!utils.isObject(criteria)) {
query.where[field] = {
'==': criteria
};
}
utils.forOwn(criteria, (value, operator) => {
let isOr = false;
let _operator = operator;
if (_operator && _operator[0] === '|') {
_operator = _operator.substr(1);
isOr = true;
}
const predicateFn = this.getOperator(_operator, opts);
if (predicateFn) {
if (isOr) {
throw new Error(`Operator ${operator} not supported!`);
} else {
dsQuery = predicateFn(dsQuery, field, value);
}
} else {
throw new Error(`Operator ${operator} not supported!`);
}
});
});
}
// Apply sort
if (query.orderBy) {
if (utils.isString(query.orderBy)) {
query.orderBy = [
[query.orderBy, 'asc']
];
}
query.orderBy.forEach((clause) => {
if (utils.isString(clause)) {
clause = [clause, 'asc'];
}
dsQuery = clause[1].toUpperCase() === 'DESC' ? dsQuery.order(clause[0], { descending: true }) : dsQuery.order(clause[0]);
});
}
// Apply skip/offset
if (query.skip) {
dsQuery = dsQuery.offset(+query.skip);
}
// Apply limit
if (query.limit) {
dsQuery = dsQuery.limit(+query.limit);
}
return dsQuery;
},
_count (mapper, query, opts) {
opts || (opts = {});
query || (query = {});
return new utils.Promise((resolve, reject) => {
let dsQuery = this.datastore.createQuery(this.getKind(mapper, opts));
dsQuery = this.filterQuery(dsQuery, query, opts).select('__key__');
this.datastore.runQuery(dsQuery, (err, entities) => {
if (err) {
return reject(err);
}
return resolve([entities ? entities.length : 0, {}]);
});
});
},
/**
* Internal method used by CloudDatastoreAdapter#_create and
* CloudDatastoreAdapter#_createMany.
*
* @method CloudDatastoreAdapter#_createHelper
* @private
* @param {object} mapper The mapper.
* @param {(Object|Object[])} records The record or records to be created.
* @return {Promise}
*/
_createHelper (mapper, records) {
const singular = !utils.isArray(records);
if (singular) {
records = [records];
}
records = utils.plainCopy(records);
return new utils.Promise((resolve, reject) => {
let apiResponse;
const idAttribute = mapper.idAttribute;
const incompleteKey = this.datastore.key([mapper.name]);
const transaction = this.datastore.transaction();
transaction.run((err) => {
if (err) {
return reject(err);
}
// Allocate ids
transaction.allocateIds(incompleteKey, records.length, (err, keys) => {
if (err) {
return reject(err);
}
const entities = records.map((_record, i) => {
utils.set(_record, idAttribute, keys[i].path[1]);
return {
key: keys[i],
data: _record
};
});
// Save records
transaction.save(entities);
apiResponse = {
created: singular ? 1 : entities.length
};
transaction.commit((err) => {
if (err) {
return reject(err);
}
// The transaction completed successfully.
return resolve([singular ? records[0] : records, apiResponse]);
});
});
});
});
},
/**
* Create a new record. Internal method used by Adapter#create.
*
* @method CloudDatastoreAdapter#_create
* @private
* @param {object} mapper The mapper.
* @param {object} props The record to be created.
* @param {object} [opts] Configuration options.
* @return {Promise}
*/
_create (mapper, props, opts) {
return this._createHelper(mapper, props, opts);
},
/**
* Create multiple records in a single batch. Internal method used by
* Adapter#createMany.
*
* @method CloudDatastoreAdapter#_createMany
* @private
* @param {object} mapper The mapper.
* @param {object} props The records to be created.
* @param {object} [opts] Configuration options.
* @return {Promise}
*/
_createMany (mapper, props, opts) {
return this._createHelper(mapper, props, opts);
},
/**
* Destroy the record with the given primary key. Internal method used by
* Adapter#destroy.
*
* @method CloudDatastoreAdapter#_destroy
* @private
* @param {object} mapper The mapper.
* @param {(string|number)} id Primary key of the record to destroy.
* response object.
* @return {Promise}
*/
_destroy (mapper, id) {
return new utils.Promise((resolve, reject) => {
this.datastore.delete(this.datastore.key([mapper.name, id]), (err, apiResponse) => {
return err ? reject(err) : resolve([undefined, apiResponse]);
});
});
},
/**
* Destroy the records that match the selection query. Internal method used by
* Adapter#destroyAll.
*
* @method CloudDatastoreAdapter#_destroyAll
* @private
* @param {object} mapper the mapper.
* @param {object} [query] Selection query.
* @return {Promise}
*/
_destroyAll (mapper, query, opts) {
return new utils.Promise((resolve, reject) => {
let dsQuery = this.datastore.createQuery(this.getKind(mapper, opts));
dsQuery = this.filterQuery(dsQuery, query, opts);
dsQuery = dsQuery.select('__key__');
this.datastore.runQuery(dsQuery, (err, entities) => {
if (err) {
return reject(err);
}
const keys = entities.map((entity) => entity.key);
this.datastore.delete(keys, (err, apiResponse) => {
if (err) {
return reject(err);
}
resolve([undefined, apiResponse]);
});
});
});
},
/**
* Retrieve the record with the given primary key. Internal method used by
* Adapter#find.
*
* @method CloudDatastoreAdapter#_find
* @private
* @param {object} mapper The mapper.
* @param {(string|number)} id Primary key of the record to retrieve.
* @param {object} [opts] Configuration options.
* @return {Promise}
*/
_find (mapper, id, opts) {
return new utils.Promise((resolve, reject) => {
const key = this.datastore.key([this.getKind(mapper, opts), id]);
this.datastore.get(key, (err, entity) => {
return err ? reject(err) : resolve([entity ? entity.data : undefined, {}]);
});
});
},
/**
* Retrieve the records that match the selection query. Internal method used
* by Adapter#findAll.
*
* @method CloudDatastoreAdapter#_findAll
* @private
* @param {object} mapper The mapper.
* @param {object} [query] Selection query.
* @param {object} [opts] Configuration options.
* @return {Promise}
*/
_findAll (mapper, query, opts) {
return new utils.Promise((resolve, reject) => {
let dsQuery = this.datastore.createQuery(this.getKind(mapper, opts));
dsQuery = this.filterQuery(dsQuery, query, opts);
this.datastore.runQuery(dsQuery, (err, entities) => {
if (err) {
return reject(err);
}
return resolve([entities ? entities.map((entity) => entity.data) : [], {}]);
});
});
},
_sum (mapper, field, query, opts) {
if (!utils.isString(field)) {
throw new Error('field must be a string!');
}
opts || (opts = {});
query || (query = {});
const canSelect = !Object.keys(query).length;
return new utils.Promise((resolve, reject) => {
let dsQuery = this.datastore.createQuery(this.getKind(mapper, opts));
dsQuery = this.filterQuery(dsQuery, query, opts);
if (canSelect) {
dsQuery = dsQuery.select(field);
}
this.datastore.runQuery(dsQuery, (err, entities) => {
if (err) {
return reject(err);
}
const sum = entities.reduce((sum, entity) => sum + (entity.data[field] || 0), 0);
return resolve([sum, {}]);
});
});
},
/**
* Internal method used by CloudDatastoreAdapter#_update and
* CloudDatastoreAdapter#_updateAll and CloudDatastoreAdapter#_updateMany.
*
* @method CloudDatastoreAdapter#_updateHelper
* @private
* @param {object} mapper The mapper.
* @param {(Object|Object[])} records The record or records to be updated.
* @param {(Object|Object[])} props The updates to apply to the record(s).
* @param {object} [opts] Configuration options.
* @return {Promise}
*/
_updateHelper (mapper, records, props, opts) {
const singular = !utils.isArray(records);
if (singular) {
records = [records];
props = [props];
}
return new utils.Promise((resolve, reject) => {
if (!records.length) {
return resolve([singular ? undefined : [], {}]);
}
const idAttribute = mapper.idAttribute;
const entities = [];
const _records = [];
records.forEach((record, i) => {
if (!record) {
return;
}
const id = utils.get(record, idAttribute);
if (!utils.isUndefined(id)) {
utils.deepMixIn(record, props[i]);
entities.push({
method: 'update',
key: this.datastore.key([this.getKind(mapper, opts), id]),
data: record
});
_records.push(record);
}
});
if (!_records.length) {
return resolve([singular ? undefined : [], {}]);
}
this.datastore.save(entities, (err, apiResponse) => {
return err ? reject(err) : resolve([singular ? _records[0] : _records, apiResponse]);
});
});
},
/**
* Apply the given update to the record with the specified primary key.
* Internal method used by Adapter#update.
*
* @method CloudDatastoreAdapter#_update
* @private
* @param {object} mapper The mapper.
* @param {(string|number)} id The primary key of the record to be updated.
* @param {object} props The update to apply to the record.
* @param {object} [opts] Configuration options.
* @return {Promise}
*/
_update (mapper, id, props, opts) {
props || (props = {});
return this._find(mapper, id, opts).then((result) => {
if (result[0]) {
props = utils.plainCopy(props);
return this._updateHelper(mapper, result[0], props, opts);
}
throw new Error('Not Found');
});
},
/**
* Apply the given update to all records that match the selection query.
* Internal method used by Adapter#updateAll.
*
* @method CloudDatastoreAdapter#_updateAll
* @private
* @param {object} mapper The mapper.
* @param {object} props The update to apply to the selected records.
* @param {object} [query] Selection query.
* @param {object} [opts] Configuration options.
* @return {Promise}
*/
_updateAll (mapper, props, query, opts) {
props || (props = {});
return this._findAll(mapper, query, opts).then((result) => {
let [records] = result;
records = records.filter((record) => record);
if (records.length) {
props = utils.plainCopy(props);
return this._updateHelper(mapper, records, records.map(() => props), opts);
}
return [[], {}];
});
},
/**
* Update the given records in a single batch. Internal method used by
* Adapter#updateMany.
*
* @method CloudDatastoreAdapter#_updateMany
* @private
* @param {object} mapper The mapper.
* @param {Object[]} records The records to update.
* @param {object} [opts] Configuration options.
* @return {Promise}
*/
_updateMany (mapper, records, opts) {
records || (records = []);
const idAttribute = mapper.idAttribute;
const tasks = records.map((record) => this._find(mapper, utils.get(record, idAttribute), opts));
return utils.Promise.all(tasks).then((results) => {
let _records = results.map((result) => result[0]);
_records.forEach((record, i) => {
if (!record) {
records[i] = undefined;
}
});
_records = _records.filter((record) => record);
records = records.filter((record) => record);
if (_records.length) {
records = utils.plainCopy(records);
return this._updateHelper(mapper, _records, records, opts);
}
return [[], {}];
});
},
loadBelongsTo (mapper, def, records, __opts) {
if (utils.isObject(records) && !utils.isArray(records)) {
return Adapter.prototype.loadBelongsTo.call(this, mapper, def, records, __opts);
}
throw new Error('findAll with belongsTo not supported!');
},
loadHasMany (mapper, def, records, __opts) {
if (utils.isObject(records) && !utils.isArray(records)) {
return Adapter.prototype.loadHasMany.call(this, mapper, def, records, __opts);
}
throw new Error('findAll with hasMany not supported!');
},
loadHasOne (mapper, def, records, __opts) {
if (utils.isObject(records) && !utils.isArray(records)) {
return Adapter.prototype.loadHasOne.call(this, mapper, def, records, __opts);
}
throw new Error('findAll with hasOne not supported!');
},
loadHasManyLocalKeys () {
throw new Error('find/findAll with hasMany & localKeys not supported!');
},
loadHasManyForeignKeys () {
throw new Error('find/findAll with hasMany & foreignKeys not supported!');
},
/**
* Resolve the Cloud Datastore kind for the specified Mapper with the given
* options.
*
* @method CloudDatastoreAdapter#getKind
* @param {object} mapper The mapper.
* @param {object} [opts] Configuration options.
* @param {object} [opts.kind] Datastore kind.
* @return {string} The kind.
*/
getKind (mapper, opts) {
opts || (opts = {});
return utils.isUndefined(opts.kind) ? (utils.isUndefined(mapper.kind) ? mapper.name : mapper.kind) : opts.kind;
},
/**
* Resolve the predicate function for the specified operator based on the
* given options and this adapter's settings.
*
* @method CloudDatastoreAdapter#getOperator
* @param {string} operator The name of the operator.
* @param {object} [opts] Configuration options.
* @param {object} [opts.operators] Override the default predicate functions
* for specified operators.
* @return {*} The predicate function for the specified operator.
*/
getOperator (operator, opts) {
opts || (opts = {});
opts.operators || (opts.operators = {});
let ownOps = this.operators || {};
return utils.isUndefined(opts.operators[operator]) ? ownOps[operator] || OPERATORS[operator] : opts.operators[operator];
}
});
/**
* Details of the current version of the `js-data-cloud-datastore` module.
*
* @example <caption>ES2015 modules import</caption>
* import {version} from 'js-data-cloud-datastore'
* console.log(version.full)
*
* @example <caption>CommonJS import</caption>
* var version = require('js-data-cloud-datastore').version
* console.log(version.full)
*
* @name module:js-data-cloud-datastore.version
* @type {object}
* @property {string} version.full The full semver value.
* @property {number} version.major The major version number.
* @property {number} version.minor The minor version number.
* @property {number} version.patch The patch version number.
* @property {(string|boolean)} version.alpha The alpha version value,
* otherwise `false` if the current version is not alpha.
* @property {(string|boolean)} version.beta The beta version value,
* otherwise `false` if the current version is not beta.
*/
export const version = '<%= version %>';
/**
* {@link CloudDatastoreAdapter} class.
*
* @example <caption>ES2015 modules import</caption>
* import {CloudDatastoreAdapter} from 'js-data-cloud-datastore'
* const adapter = new CloudDatastoreAdapter()
*
* @example <caption>CommonJS import</caption>
* var CloudDatastoreAdapter = require('js-data-cloud-datastore').CloudDatastoreAdapter
* var adapter = new CloudDatastoreAdapter()
*
* @name module:js-data-cloud-datastore.CloudDatastoreAdapter
* @see CloudDatastoreAdapter
* @type {Constructor}
*/
/**
* Registered as `js-data-cloud-datastore` in NPM.
*
* @example <caption>Install from NPM</caption>
* npm i --save js-data-cloud-datastore@rc js-data@rc @google-cloud/datastore
*
* @example <caption>ES2015 modules import</caption>
* import {CloudDatastoreAdapter} from 'js-data-cloud-datastore'
* const adapter = new CloudDatastoreAdapter()
*
* @example <caption>CommonJS import</caption>
* var CloudDatastoreAdapter = require('js-data-cloud-datastore').CloudDatastoreAdapter
* var adapter = new CloudDatastoreAdapter()
*
* @module js-data-cloud-datastore
*/