js-data-cloudmine
Version:
CloudMine adapter for js-data.
728 lines (664 loc) • 22.4 kB
JavaScript
import { utils } from 'js-data'
import { Adapter } from '../node_modules/js-data-adapter/src/index'
function isValidString (value) {
return (value != null && value !== '')
}
function join (items, separator) {
separator || (separator = '')
return items.filter(isValidString).join(separator)
}
function makePath (...args) { // eslint-disable-line no-unused-vars
let result = join(args, '/')
return result.replace(/([^:\/]|^)\/{2,}/g, '$1/')
}
let queue = []
let taskInProcess = false
function enqueue (task) {
queue.push(task)
}
function dequeue () {
if (queue.length && !taskInProcess) {
taskInProcess = true
queue[ 0 ]()
}
}
function queueTask (task) {
if (!queue.length) {
enqueue(task)
dequeue()
} else {
enqueue(task)
}
}
function createTask (fn) {
return new utils.Promise(fn).then((result) => {
taskInProcess = false
queue.shift()
setTimeout(dequeue, 0)
return result
}, (err) => {
taskInProcess = false
queue.shift()
setTimeout(dequeue, 0)
return utils.reject(err)
})
}
function APICallToPromise (ac, opts) {
return new utils.Promise((resolve, reject) => {
ac.on('success', (data, response) => {
resolve([ data, response ])
})
.on('error', (data, response) => {
// throw new Error(data)
reject([ data, response ])
})
// Tack on any extra jQuery like options.
if (opts.meta) {
ac.on('meta', opts.meta)
}
if (opts.result) {
ac.on('result', opts.result)
}
if (opts.complete) {
ac.on('complete', opts.complete)
}
})
}
const __super__ = Adapter.prototype
/**
* CloudMineAdapter class.
*
* @example <caption>Browser</caption>
* import {DataStore} from 'js-data'
* import cloudmine from 'cloudmine'
* import {CloudMineAdapter} from 'js-data-cloudmine'
* const store = new DataStore()
* cloudmine.initializeApp({
* apiKey: 'your-api-key',
* databaseURL: 'your-database-url'
* })
* const adapter = new CloudMineAdapter({ db: cloudmine.database() })
* store.registerAdapter('cloudmine', adapter, { 'default': true })
*
* @example <caption>Node.js</caption>
* import {Container} from 'js-data'
* import cloudmine from 'cloudmine'
* import {CloudMineAdapter} from 'js-data-cloudmine'
* const store = new Container()
* cloudmine.initializeApp({
* databaseURL: 'your-database-url',
* serviceAccount: 'path/to/keyfile'
* })
* const adapter = new CloudMineAdapter({ db: cloudmine.database() })
* store.registerAdapter('cloudmine', adapter, { 'default': true })
*
* @class CloudMineAdapter
* @param {Object} [opts] Configuration opts.
* @param {Object} [opts.ws] See {@link CloudMineAdapter#ws}
* @param {boolean} [opts.debug=false] See {@link Adapter#debug}.
* @param {boolean} [opts.raw=false] See {@link Adapter#raw}.
*/
export function CloudMineAdapter (opts) {
utils.classCallCheck(this, CloudMineAdapter)
opts || (opts = {})
Adapter.call(this, opts)
/**
* The service instance used by this adapter.
*
* @name CloudMineAdapter#ws
* @type {Object}
*/
this.ws = opts.ws
}
// Setup prototype inheritance from Adapter
CloudMineAdapter.prototype = Object.create(Adapter.prototype, {
constructor: {
value: CloudMineAdapter,
enumerable: false,
writable: true,
configurable: true
}
})
Object.defineProperty(CloudMineAdapter, '__super__', {
configurable: true,
value: Adapter
})
/**
* Alternative to ES6 class syntax for extending `CloudMineAdapter`.
*
* @example <caption>Using the ES2015 class syntax.</caption>
* class MyCloudMineAdapter extends CloudMineAdapter {...}
* const adapter = new MyCloudMineAdapter()
*
* @example <caption>Using {@link CloudMineAdapter.extend}.</caption>
* var instanceProps = {...}
* var classProps = {...}
*
* var MyCloudMineAdapter = CloudMineAdapter.extend(instanceProps, classProps)
* var adapter = new MyCloudMineAdapter()
*
* @method CloudMineAdapter.extend
* @static
* @param {Object} [instanceProps] Properties that will be added to the
* prototype of the subclass.
* @param {Object} [classProps] Properties that will be added as static
* properties to the subclass itself.
* @return {Constructor} Subclass of `CloudMineAdapter`.
*/
CloudMineAdapter.extend = utils.extend
utils.addHiddenPropsToTarget(CloudMineAdapter.prototype, {
/**
* Retrieve the number of records that match the selection query. Internal
* method used by Adapter#count.
*
* @name CloudMineAdapter#_count
* @method
* @private
* @param {Object} mapper The mapper.
* @param {Object} query Selection query.
* @param {Object} [opts] Configuration options.
* @return {Promise}
*/
_count (mapper, query, opts) {
query || (query = {})
opts || (opts = {})
query.__mapper__ = mapper.name
query.count = true
return this._findAll(mapper, query, opts).then((result) => {
result[ 0 ] = result[ 1 ].count
result[ 1 ].data = result[ 1 ].count
return result
})
},
/**
* Create a new record. Internal method used by Adapter#create.
*
* @name CloudMineAdapter#_create
* @method
* @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) {
props || (props = {})
opts || (opts = {})
return this._upsert(mapper, props, opts)
},
_upsert (mapper, props, opts) {
const _props = utils.plainCopy(props)
opts || (opts = {})
let id = utils.get(_props, mapper.idAttribute)
if (!utils.isSorN(id)) {
id = null
}
_props.__mapper__ = mapper.name
return APICallToPromise(this.ws.set(id, _props), opts)
.then((result) => {
let record = result[ 0 ]
utils.set(_props, mapper.idAttribute, Object.keys(record)[ 0 ])
if (!record) {
throw new Error('Not Found')
}
// Get rid of this since it is internal.
delete _props.__mapper__
return [ _props ]
})
},
_upsertBatch (mapper, records, opts) {
opts || (opts = {})
const updates = []
// generate path for each
records.forEach((record) => {
updates.push(this._upsert(mapper, record, opts))
})
return utils.Promise.all(updates)
.then((values) => {
return values.map(item => item[ 0 ])
})
.then((records) => {
return [ records ]
})
},
/**
* Create multiple records in a single batch. Internal method used by
* Adapter#createMany.
*
* @name CloudMineAdapter#_createMany
* @method
* @private
* @param {Object} mapper The mapper.
* @param {Object} records The records to be created.
* @param {Object} [opts] Configuration options.
* @return {Promise}
*/
_createMany (mapper, records, opts) {
opts || (opts = {})
return this._upsertBatch(mapper, records, opts)
},
/**
* Destroy the record with the given primary key. Internal method used by
* Adapter#destroy.
*
* @name CloudMineAdapter#_destroy
* @method
* @private
* @param {Object} mapper The mapper.
* @param {(string|number)} id Primary key of the record to destroy.
* @param {Object} [opts] Configuration options.
* @return {Promise}
*/
_destroy (mapper, id, opts) {
opts || (opts = {})
return APICallToPromise(this.ws.destroy(id), opts)
.then(() => [ undefined, {} ])
.catch((error) => {
console.error(error)
return [ undefined, {} ]
})
},
/**
* Destroy the records that match the selection query. Internal method used by
* Adapter#destroyAll.
*
* @name CloudMineAdapter#_destroyAll
* @method
* @private
* @param {Object} mapper the mapper.
* @param {Object} [query] Selection query.
* @param {Object} [opts] Configuration options.
* @return {Promise}
*/
_destroyAll (mapper, query, opts) {
query || (query = {})
opts || (opts = {})
// We need this to do bulk deletes using CloudMine.
query.__mapper__ = mapper.name
return this._findAll(mapper, query)
.then((results) => {
const [records] = results
const idAttribute = mapper.idAttribute
return utils.Promise.all(records.map((record) => {
return this._destroy(mapper, utils.get(record, idAttribute), opts)
}))
})
.then(() => [ undefined, {} ])
},
/**
* Retrieve the record with the given primary key. Internal method used by
* Adapter#find.
*
* @name CloudMineAdapter#_find
* @method
* @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) {
opts || (opts = {})
return APICallToPromise(this.ws.get(id), opts)
.then((result) => {
let record = result[ 0 ][ id ]
utils.set(record, mapper.idAttribute, id)
delete record.__mapper__
result[ 0 ] = record
return [ record, {} ]
})
.catch(function (error) {
console.error(error.stack)
return [ undefined, {} ]
})
},
/**
* Retrieve the records that match the selection query. Internal method used
* by Adapter#findAll.
*
* @name CloudMineAdapter#_findAll
* @method
* @private
* @param {Object} mapper The mapper.
* @param {Object} query Selection query.
* @param {Object} [opts] Configuration options.
* @return {Promise}
*/
_findAll (mapper, query, opts) {
query || (query = {})
opts || (opts = {})
let queryopts = {}
let realquery = []
let findAllPromise
if (query.count !== undefined) {
queryopts = { count: query.count }
delete query.count
}
// We were passed a query.
if (utils.isObject(query.where) && Object.keys(query.where).length === 1) {
// The query contains the idAttribute of the mapper as an option.
let op = Object.keys(query.where[Object.keys(query.where)[0]])[0]
if (query.where.hasOwnProperty(mapper.idAttribute)) {
// It's a number. Use .get() to grab it and return it.
if (utils.isString(query.where[ mapper.idAttribute ])) {
findAllPromise = APICallToPromise(this.ws.get(query.where[ mapper.idAttribute ]), opts)
// It's not just a number, it's got an additional object.
} else if (utils.isObject(query.where[ mapper.idAttribute ]) && (query.where[ mapper.idAttribute ].hasOwnProperty('in') || query.where[ mapper.idAttribute ].hasOwnProperty('=='))) {
// If there is nothing here, don't bother.
if (op === 'in' && query.where[ mapper.idAttribute ].length === 0) {
return Promise.resolve([ undefined, {} ])
}
findAllPromise = APICallToPromise(this.ws.get(query.where[ mapper.idAttribute ][ op ]), opts)
} else {
// There is a chance that the operation is unsupported because in some cases we end up having relations
// with filters and the like, but it is possible that we want to do a query where we only want a record
// returned if it has been updated (e.g. [id = "abc...", updated > 1234345]). In which case, we want search
// to be used instead of delegating over to the regular operation.
// So for now, we'll remove these warnings because they cause that functionality to be broken.
// console.error('Unsupported operation.')
// throw new Error('Unsupported operation.')
}
} else {
// If we have a relation Field...
var searchField = Object.keys(query.where)[0]
if (mapper.hasOwnProperty('relationList')) {
mapper.relationList.forEach((rel) => {
if (rel.foreignKey === searchField && rel.mapper !== mapper) {
findAllPromise = APICallToPromise(this.ws.get(query.where[ searchField ][ op ]), opts)
}
})
}
if (mapper.hasOwnProperty('relationFields')) {
if (mapper.relationFields.indexOf(Object.keys(query.where)[ 0 ]) !== false) {
let fieldName = Object.keys(query.where)[ 0 ]
let thisField
mapper.relationList.forEach((def) => {
if (def.localField === fieldName) {
thisField = def
}
})
if (thisField) {
// @todo this will really only work if the remote key is an ID
let op = Object.keys(query.where[ fieldName ])[ 0 ]
findAllPromise = APICallToPromise(this.ws.get(query.where[ fieldName ][ op ]), opts)
}
// Otherwise, we'll query this mapper normally.
}
}
}
// Note that 'in' or 'contains' with multiple values are not supported right now.
// According to the CloudMine docs, a ws.search() request can only contain
// ", " (and) or " or " (or) operations in a single request.
// @see https://cloudmine.io/docs/#/javascript#searching-for-objects
Object.keys(query.where).forEach((key) => {
if (utils.isObject(query.where[ key ])) {
Object.keys(query.where[ key ]).forEach((op) => {
let value
let realop = op === '==' ? '=' : op
if (utils.isNumber(query.where[ key ][ op ])) {
value = query.where[ key ][ op ]
} else if (op === 'in' && utils.isArray(query.where[ key ][ op ])) {
let inClause = []
query.where[ key ][ op ].forEach((item) => {
inClause.push(utils.isNumber(item) ? `${key} = ${item}` : `${key} = "${item}"`)
})
realquery.push(inClause.join(' or '))
return
} else {
value = `"${query.where[ key ][ op ]}"`
}
realquery.push(`${key} ${realop} ${value}`)
})
} else {
let value
if (utils.isNumber(query.where[ key ])) {
value = query.where[ key ]
} else {
value = `"${query.where[ key ]}"`
}
realquery.push(`${key} = ${value}`)
}
})
}
// CloudMine has some interesting... quirks. As we noted above, you cannot mix
// "and (,)" and "or" operators in the same search query. This is problematic when
// loading relationships because we basically say, "give us every object of type X
// where value = Y". And since CloudMine uses a field to keep track of object type,
// loading multiple object types in the same query won't work because we or the search
// and then add in the "and" for the __mapper__.
// The workaround. When _activeWith is not undefined, we skip adding the __mapper__ to
// the query and then filter the results manually once they get back. This way the query
// doesn't get mixed and the results are what are desired.
if (opts._activeWith === undefined) {
query.__mapper__ = mapper.name
}
// Attempt to convert the JSData query object to something Cloudmine can understand...
Object.keys(query).forEach((key) => {
if (key === 'where') return
if (utils.isObject(query[ key ])) {
Object.keys(query[ key ]).forEach((op) => {
let value
let realop = op === '==' ? '=' : op
if (utils.isNumber(query[ key ][ op ])) {
value = query[ key ][ op ]
} else {
value = `"${query[ key ][ op ]}"`
}
realquery.push(`${key} ${realop} ${value}`)
})
} else {
let value
if (utils.isNumber(query[ key ])) {
value = query[ key ]
} else {
value = `"${query[ key ]}"`
}
realquery.push(`${key} = ${value}`)
}
})
if (!findAllPromise) {
findAllPromise = APICallToPromise(this.ws.search('[' + realquery.join(', ') + ']', queryopts), opts)
}
return findAllPromise.then((result) => {
let results = []
let records = result[ 0 ]
for (let id in records) {
let record = records[ id ]
if (opts._activeWith !== undefined && record.__mapper__ !== mapper.name) {
continue
}
utils.set(record, mapper.idAttribute, id)
delete record.__mapper__
results.push(record)
}
result[ 0 ] = results
delete result[ 1 ]._events
return result
})
},
/**
* Retrieve the number of records that match the selection query. Internal
* method used by Adapter#sum.
*
* @name CloudMineAdapter#_sum
* @method
* @private
* @param {Object} mapper The mapper.
* @param {string} field The field to sum.
* @param {Object} query Selection query.
* @param {Object} [opts] Configuration options.
* @return {Promise}
*/
_sum (mapper, field, query, opts) {
return this._findAll(mapper, query, opts).then((result) => {
result[ 0 ] = result[ 0 ].reduce((sum, record) => sum + (utils.get(record, field) || 0), 0)
return result
})
},
/**
* Apply the given update to the record with the specified primary key.
* Internal method used by Adapter#update.
*
* @name CloudMineAdapter#_update
* @method
* @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 = {})
opts || (opts = {})
return this._find(mapper, id)
.then((currentVal) => {
currentVal = currentVal[ 0 ]
if (!currentVal) {
throw new Error('Not Found')
}
utils.deepMixIn(currentVal, props)
return this._upsert(mapper, currentVal, opts)
})
.then((record) => {
if (!record) {
throw new Error('Not Found')
}
return record
})
},
/**
* Apply the given update to all records that match the selection query.
* Internal method used by Adapter#updateAll.
*
* @name CloudMineAdapter#_updateAll
* @method
* @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) {
opts || (opts = {})
props || (props = {})
query || (query = {})
return this._findAll(mapper, query, opts).then((results) => {
const [records] = results
records.forEach((record) => utils.deepMixIn(record, props))
return this._upsertBatch(mapper, records, opts)
})
},
/**
* Update the given records in a single batch. Internal method used by
* Adapter#updateMany.
*
* @name CloudMineAdapter#updateMany
* @method
* @private
* @param {Object} mapper The mapper.
* @param {Object[]} records The records to update.
* @param {Object} [opts] Configuration options.
* @return {Promise}
*/
_updateMany (mapper, records, opts) {
opts || (opts = {})
return this._upsertBatch(mapper, records, opts)
},
create (mapper, props, opts) {
return createTask((success, failure) => {
queueTask(() => {
__super__.create.call(this, mapper, props, opts).then(success, failure)
})
})
},
createMany (mapper, props, opts) {
return createTask((success, failure) => {
queueTask(() => {
__super__.createMany.call(this, mapper, props, opts).then(success, failure)
})
})
},
destroy (mapper, id, opts) {
return createTask((success, failure) => {
queueTask(() => {
__super__.destroy.call(this, mapper, id, opts).then(success, failure)
})
})
},
destroyAll (mapper, query, opts) {
return createTask((success, failure) => {
queueTask(() => {
__super__.destroyAll.call(this, mapper, query, opts).then(success, failure)
})
})
},
update (mapper, id, props, opts) {
return createTask((success, failure) => {
queueTask(() => {
__super__.update.call(this, mapper, id, props, opts).then(success, failure)
})
})
},
updateAll (mapper, props, query, opts) {
return createTask((success, failure) => {
queueTask(() => {
__super__.updateAll.call(this, mapper, props, query, opts).then(success, failure)
})
})
},
updateMany (mapper, records, opts) {
return createTask((success, failure) => {
queueTask(() => {
__super__.updateMany.call(this, mapper, records, opts).then(success, failure)
})
})
}
})
/**
* Details of the current version of the `js-data-cloudmine` module.
*
* @name CloudMineAdapter.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 CloudMineAdapter} class.
*
* @name module:js-data-cloudmine.CloudMineAdapter
* @see CloudMineAdapter
*/
/**
* Registered as `js-data-cloudmine` in NPM and Bower.
*
* @example <caption>Script tag</caption>
* var CloudMineAdapter = window.JSDataCloudMine.CloudMineAdapter
* var adapter = new CloudMineAdapter()
*
* @example <caption>CommonJS</caption>
* var CloudMineAdapter = require('js-data-cloudmine').CloudMineAdapter
* var adapter = new CloudMineAdapter()
*
* @example <caption>ES2015 Modules</caption>
* import {CloudMineAdapter} from 'js-data-cloudmine'
* const adapter = new CloudMineAdapter()
*
* @example <caption>AMD</caption>
* define('myApp', ['js-data-cloudmine'], function (JSDataCloudMine) {
* var CloudMineAdapter = JSDataCloudMine.CloudMineAdapter
* var adapter = new CloudMineAdapter()
*
* // ...
* })
*
* @module js-data-cloudmine
*/