@momsfriendlydevco/moody
Version:
MOngoose On DYnamo - Wrapper for AWS, bringing the API closer to Mongoose
493 lines (431 loc) • 14.1 kB
JavaScript
var _ = require('lodash');
var debug = require('debug')('moody');
var debugDetail = require('debug')('moody:detail');
var sift = require('sift').default;
module.exports = function Moomyquery(model, filter) {
var myq = this;
myq.my = model.my;
myq.model = model;
myq.query = {
table: myq.model.id,
index: undefined,
action: 'find',
actionPayload: undefined,
count: false,
limit: 0,
all: false,
skip: 0,
startAt: false,
filters: {},
filtersSift: {}, // Additional Sift criteria to filter by - only works if myq.soft.query is enabled, which is enabled via the `.sift()` wrapper
select: [],
sort: [],
flatten: false,
lean: false,
cache: false, // Falsy or Millisecond expiry time
...myq.my.settings.query,
};
/**
* Append query criteria
* @param {Object} query Query to append
* @returns {MoodyQuery} This chainable query
*/
myq.find = query => {
Object.assign(myq.query.filters, query);
return myq;
};
// Populate initial query
if (filter) myq.find(filter);
/**
* Mark the query as a count - which will only return the number of matches
* @param {Object} query Optional to append
* @returns {MoodyQuery} This chainable query
*/
myq.count = query => {
myq.query.count = true;
return myq.find(query);
};
/**
* Append Sift query criteria
* This also enables soft.query
* @param {Object|boolean} [filter...] The sift filters to apply, if the only parameter is a boolean soft quering is enabled or disabled by its value, if its an object soft quering is enabled AND the object is used as the query
* @returns {MoodyQuery} This chainable query
*/
myq.sift = (...filter) => {
if (filter.length == 1 && _.isBoolean(filter[0])) {
myq.query.soft.query = filter[0];
} else {
myq.query.soft.query = true;
_.merge(myq.query.filtersSift, ...filter);
}
return myq;
};
/**
* Set the limit of documents
* If the value is zero, falsy or Infinity the limit is disabled
* @param {*} limit The limit to set
* @returns {MoodyQuery} This chainable query
*/
myq.limit = limit => {
myq.query.limit =
!limit || limit === Infinity ? 0
: limit;
return myq;
};
/**
* Set the skip for documents (i.e. the paginated offset of responses)
* If the value is zero or falsy the skip is disabled
* @param {*} skip The skip offset to set
* @returns {MoodyQuery} This chainable query
*/
myq.skip = skip => {
myq.query.skip = !skip ? 0 : skip;
return myq;
};
/**
* Set an action to perform when the query executes
* This function is not intended for user use and is usually mapped via operations such as `model.updateOneByID(id, patch)`
* @param {string} action The action to perform
* @param {*} [actionPayload] Optional action payload to also set
* @returns {MoodyQuery} This chainable query
*/
myq.action = (action, actionPayload) => {
Object.assign(myq.query, {action, actionPayload});
return myq;
};
/**
* Perform a query and delete all resulting records
* Alias for `query.action('delete')`
* @returns {MoodyQuery} This chainable query
*/
myq.delete = ()=> myq.action('delete');
/**
* Perform a query and update all resulting records
* Note that if `lean` is enabled virtuals and fields with the `value` attribute cannot be processed also
* Alias for `query.action('update', fields)`
* @returns {MoodyQuery} This chainable query
*/
myq.update = fields => myq.action('update', fields);
/**
* Set the fields to return (or "Project" to use proper DB terminology)
* Fields can be specified in array or CSV form
* @param {string|array} fields... Fields to return
* @returns {MoodyQuery} This chainable query
*/
myq.select = (...fields) => {
if (!fields.length || _.isEqual(fields, [undefined])) return myq; // Nothing to do
myq.query.select = Array.from(new Set(
_(fields.concat([model.settings.idField])) // Glue Id field onto select
.map(f => _.isString(f) ? f.split(/\s*,\s*/) : f)
.flatten()
.concat(myq.query.select)
.filter()
.value()
));
return myq;
};
/**
* Set the sort criteria of the query
* @param {string|array} sort... Sort criteria to add, can be a string, array or CSVs
* @returns {MoodyQuery} This chainable query
*/
myq.sort = (...fields) => {
if (!fields.length || _.isEqual(fields, [undefined])) return myq; // Nothing to do
myq.query.sort = Array.from(new Set(
_(fields)
.map(f => _.isString(f) ? f.split(/\s*,\s*/) : f)
.flatten()
.concat(myq.query.sort)
.filter()
.value()
));
return myq;
};
/**
* Set that we are only interested in the first match and it should be returned directly as an object rather than an array of matches
* @returns {MoodyQuery} This chainable query
*/
myq.one = ()=> {
myq.query.limit = 1;
myq.query.flatten = true;
return myq;
};
/**
* Set a starting index for the query to begin at
* NOTE: Dynamo only accepts keys in dynamo form i.e. {id: S: {'***********'}}
* @returns {MoodyQuery} This chainable query
*/
myq.startAt = index => {
myq.query.startAt = index;
return myq;
};
/**
* Set that we want responses exceeding Dynamo's 1MB response limit, this will repeatedly query via lastKey callback
* @returns {MoodyQuery} This chainable query
*/
myq.all = ()=> {
myq.query.all = true;
return myq;
};
/**
* Set the lean property - i.e. don't try to decorate the response documents
* @returns {MoodyQuery} This chainable query
*/
myq.lean = ()=> {
myq.query.lean = true;
return myq;
};
/**
* Manually specify the index to use
* @param {string} index The index name to force
* @returns {MoodyQuery} This chainable query
*/
myq.using = index => {
myq.query.index = index;
return myq;
};
/**
* Set the cache bit
* This means that colliding requests within the same cache frequency will recieve the same result
* @param {string} [timeout] A timestring parsable expression indicating the maximum timeout
* @returns {MoodyQuery} This chainable query
*/
myq.cached = myq.cache = timeout => {
myq.query.cache = _.isString(timeout)
? timestring(timeout)
: myq.my.settings.cache.defaultTime;
return myq;
},
/**
* Execute the query and return a promise
* @returns {Promise <Object|array|undefined>} A promise which returns the found document, collection of documents or undefined
*
* @emits queryScan Emitted as `(queryData)` when falling back to using a scan
* @emits query Emitted as `(queryData)` when running a query with a valid index
*/
myq.exec = myq.promise = ()=> Promise.resolve()
// Execute query, returning a promise {{{
.then(()=> {
if (
_.size(myq.query.filters) // We have at least one filter
&& (
!myq.my.settings.indexes.forceScan // Not being forced to scan
|| myq.query.index // OR we're forcing an index
)
) {
myq.my.emit('query', myq.query);
debugDetail('Execute query (via query w/index)', myq.query);
return new Promise((resolve, reject) => {
var q = myq.model.model;
q = myq.my.translateMongo(myq.query.filters, q);
if (myq.query.count) q.count();
if (myq.query.limit) q.limit(myq.query.limit);
if (myq.query.index) q.using(myq.query.index);
if (myq.query.selected) q.attributes(myq.query.selected);
if (myq.query.startAt) q.startAt(myq.query.startAt);
q.exec(async(err, res) => {
debugDetail('Query finish', err, res);
if (err) debugDetail('Query rejected', err.toString());
if (err) return reject(err);
if(!res.lastKey || !myq.query.all){
if (myq.query.count) {
var count = res[0] || 0;
debugDetail('Counted', count, 'docs from query');
resolve(count);
} else {
debugDetail('Found', res.length, 'docs from query');
resolve(res);
}
}
else {
myq.query.startAt = res.lastKey;
await myq.exec()
.then(moreData => res = res.concat(moreData))
.then(data => {
resolve(data)
})
}
});
});
} else {
debugDetail('Execute query (via scan)', myq.query);
if (myq.my.settings.indexes.scanWarning) console.log('WARN', 'Using scan instead of index based query for', myq.query);
myq.my.emit('queryScan', myq.query);
return new Promise((resolve, reject) => {
var q = myq.model.model.scan(myq.query.filters);
if (myq.query.count) q.count();
if (myq.query.limit) q.limit(myq.query.limit);
if (myq.query.index) q.using(myq.query.index);
if (myq.query.selected) q.attributes(myq.query.selected);
if (myq.query.startAt) q.startAt(myq.query.startAt);
q.exec(async(err, res) => {
if (err) return reject(err);
if(!res.lastKey || !myq.query.all){
if (myq.query.count) {
var count = res[0] || 0;
debugDetail('Counted', count, 'docs from query');
resolve(count);
} else {
debugDetail('Found', res.length, 'docs from query');
resolve(res);
}
}
else {
myq.query.startAt = res.lastKey;
await myq.exec()
.then(moreData => res = res.concat(moreData))
.then(data => {
resolve(data)
})
}
});
});
}
})
// }}}
// Soft query (suplement the "hard" DB query with Sift) {{{
.then(res => {
if (!myq.query.soft.query || !_.isArray(res)) {
return res; // Not an enabled or a filterable array anyway
} else {
var siftQuery = {...myq.query.filters, ...myq.query.filtersSift};
debugDetail('Applying sift query', siftQuery);
return res.filter(sift(siftQuery));
}
})
// }}}
// Soft select {{{
.then(res => {
if (!myq.query.soft.select || !_.isArray(res)) {
return res; // Not an array or enabled anyway
} else if (!myq.query.select.length && !Object.keys(model.virtuals).length) { // Not selecting anything and we have no virtuals
return res;
} else if (Object.keys(model.virtuals).length && !myq.query.select.length) { // Not selecting but we have virtuals to flatten
return res.map(doc =>
Object.keys(model.virtuals).reduce((t, k) => {
t[k] = model.virtuals[k].get.call(doc, doc);
return t;
}, doc)
);
} else { // Selecting AND we have virtuals
return res.map(doc =>
myq.query.select.reduce((t, k) => {
if (model.virtuals[k]) { // Is a virtual
t[k] = model.virtuals[k].get.call(doc, doc);
} else { // Is presumably a regular key
t[k] = doc[k];
}
return t;
}, {})
)
}
})
// }}}
// Soft sort {{{
.then(res => {
if (!myq.query.soft.sort || !myq.query.sort.length || !_.isArray(res)) {
return res; // Not an array or enabled anyway
} else { // Selecting AND we have virtuals
return _.orderBy(res, ..._(myq.query.sort)
.map(o =>
o.startsWith('-') ? [o.substr(1), 'desc']
: o.startsWith('+') ? [o.substr(1), 'asc']
: [o, 'asc']
)
.unzip()
.value()
);
}
})
// }}}
// Decorate documents (if !query.lean) {{{
.then(res => _.isArray(res) && !myq.query.lean
? res.map(doc => new myq.my.Document(model, doc))
: res
)
// }}}
// Perform post operation actions {{{
.then(res => {
if (!_.isArray(res)) return res;
if (myq.query.action == 'update') {
debug('Update', res.length, 'documents via exec post action');
return Promise.all(res.map(doc => Promise.resolve()
.then(()=>
myq.query.lean
? model.updateOneById(doc[model.settings.idField], myq.query.actionPayload)
: doc.save(myq.query.actionPayload)
)
.then(()=> myq.query.lean ? undefined : doc)
.catch(e => debug('Error when updating', doc[model.settings.idField], '-', e))
));
} else if (myq.query.action == 'delete') {
debug('Delete', res.length, 'documents via exec post action');
return Promise.all(res.map(doc =>
myq.query.lean
? model.deleteOneById(doc[model.settings.idField])
: doc.delete()
));
} else {
return res;
}
})
// }}}
// Perform final flatten {{{
.then(res =>
myq.query.flatten
? res[0]
: res
)
// }}}
/**
* Return a promise and immediately execute it
* This is really just an alias for myq.promise().then(...)
* @param {*} args... Callback to run
* @returns {Promise}
*/
myq.then = (...args) => myq.promise().then(...args);
/**
* Return a promise and immediately execute it as a catch
* This is really just an alias for myq.promise().catch(...)
* @param {*} args... Callback to run
* @returns {Promise}
*/
myq.catch = (...args) => myq.promise().catch(...args);
/**
* Return a promise and immediately execute it as a finally block
* This is really just an alias for myq.promise().finally(...)
* @param {*} args... Callback to run
* @returns {Promise}
*/
myq.finally = (...args) => myq.promise().finally(...args);
/**
* Return whether a model index exists which matches the query
* * NOTE: This is not the same as the queryOptimizer used by Dynamoose
* @param {Object} query The query to examine
* @returns {string|undefined} The name of the index if one exists
*/
myq.getIndex = ()=>
myq.model.describe()
.then(spec => {
var wantKeysSet = new Set(_.keys(myq.query.filters));
if (!wantKeysSet.size) return undefined; // Cannot use indexes with 'find all' queries
var wantSortSet = new Set(myq.query.sort);
debug('SPEC', JSON.stringify(spec.moodyIndexLookup, null, '\t'));
var useIndex = _(spec.moodyIndexLookup)
.map(i => {
i.filterScore = i.filters.reduce((t, v) => wantKeysSet.has(v) ? t + 1 : t, 0);
i.sortScore = myq.query.sort.every(s => wantSortSet.has(s)) ? 1 : 0;
return i;
})
.sortBy([
'filterScore',
'sortScore',
(a, b) => a.index == 'partitionKey' ? -1 : 1, // Bias towards partition key if we can't guess
])
.first();
debug('WANT', 'FILTERS:', Array.from(wantKeysSet), 'SORTS:', myq.query.sort);
debug('COMPUTES TO', JSON.stringify(useIndex, null, '\t'));
return useIndex ? useIndex.index : undefined;
})
return myq;
};