@momsfriendlydevco/moody
Version:
MOngoose On DYnamo - Wrapper for AWS, bringing the API closer to Mongoose
689 lines (588 loc) • 23.1 kB
JavaScript
var _ = require('lodash');
var debug = require('debug')('moody');
var debugDetail = require('debug')('moody:detail');
module.exports = function(my, id, schema, options) {
var mym = this;
mym.id = id;
mym.schema = schema;
mym.my = my;
mym.settings = {
idField: 'id',
...options,
};
// Document defaults {{{
mym.prototype = {
/**
* Iterate down a document schema path running a function on all matching endpoints
* @param {string|array} schemaPath Schema path expression to traverse
* @param {function} func A promise compatible function called as `(docPath, schemaPath)` on each endpoint node
* @returns {Promise} A promise which will resolve when all functions have completed execution
*/
$each(path, func) {
if (!_.isArray(path)) path = path.split('.');
var waitingOn = [];
debugDetail(`$EACH BEGIN ${path} ----------------------`);
var traverse = (offset, docPath, docContext, schemaPath, schemaContext) => {
debugDetail('$EACH PATH', path.slice(0, offset));
var segment = path[offset];
if (_.isPlainObject(schemaContext[segment])) {
debugDetail('$EACH INTO OBJECT', {docPath, schemaPath, segment, docContext});
if (!_.isPlainObject(docContext)) { // Path doesn't exist but it has a document path designation
var newNodePath = docPath.concat([segment]);
_.set(this, newNodePath, {});
docContext = _.get(this, newNodePath);
debug('Muated this to', newNodePath, 'INTO', docContext);
}
traverse(offset + 1, docPath.concat([segment]), docContext[segment], schemaPath.concat([segment]), schemaContext[segment]);
} else if (_.isPlainObject(schemaContext[segment]) && schemaContext[segment].type == 'list') { // Array using Dynamo syntax
debugDetail('$EACH INTO DY ARRAY', schemaPath);
docContext[segment].list.forEach((v, index) =>
traverse(offset, docPath.concat([index]), docContext[index], schemaPath, schemaContext)
);
} else if (_.isArray(schemaContext[segment])) { // Array using JS syntax
debugDetail('$EACH INTO JS ARRAY', {docPath, schemaPath, segment, INTO: docContext});
if (!_.isArray(docContext[segment])) return; // Array doesn't exist yet
docContext[segment].forEach((v, index) =>
traverse(offset + 1, docPath.concat([segment, index]), docContext[index], schemaPath.concat([segment]), schemaContext)
);
} else {
debugDetail('$EACH HIT NODE', {docPath, schemaPath});
waitingOn.push(Promise.resolve(func(
segment ? docPath.concat([segment]) : docPath,
segment ? schemaPath.concat([segment]) : schemaPath,
)));
}
};
traverse(0, [], this, [], mym.schema);
debugDetail('$EACH END ======================');
return Promise.all(waitingOn);
},
/**
* Iterate down a document mapping all matching endpoints
* With one (dotted notation) path this acts the same as _.set() but if any of the nodes are arrays all branching endpoints are mapped via the function
* Note that this is now 'schema aware' so in the majority of cases $each is required instead
* @param {string|array} path Path expression to traverse
* @param {Promise|function} func Function to run on each endpoint, called as (val, path, doc), you can replace values with this.$set(path, newValue)
* @param {Object} [context=this] Context to travese, relative to the path, defaults to the current document root
* @param {string} [currentPath] The current absolute path, used for debugging
* @returns {Promise} A promise which will resolve when all functions have completed
*/
$eachDocumentNode(path, func, context = this, currentPath = []) {
var rootDoc = this;
if (!_.isArray(path)) path = path.split('.');
var traverse = (path, context, currentPath) => {
var segment = path.shift();
if (_.isObject(context[segment])) { // Segment is traversable
return Promise.all(
_.map(context[segment], (v, k) =>
traverse(path, context[segment][k] || segment, currentPath.concat([segment, k]))
)
);
} else if (path.length == 0 && _.isObject(context)) { // Found final leaf
return Promise.resolve(func.call(rootDoc, context[segment], currentPath.concat([segment]), rootDoc));
}
};
return traverse(path, context, currentPath);
},
/**
* Set the value of a dotted notation path, evaluating the value if its a promise
* Note: Unlike $each this does not resolve relative to the schema path, just the plain object
* @param {string|array} path Path to set in either dotted notation or array format
* @param {*} val The value to set, if this is a function it is evaluated as a promise before completing
* @returns {MoodyDocument} This moody document context
*/
$set(path, val) {
if (_.isFunction(val)) {
return Promise.resolve(val(doc))
.then(res => _.set(this, path, res))
.then(()=> this)
} else {
_.set(this, path, val);
return Promise.resolve(this);
}
},
/**
* Convert the curent Moody document to a plain object
* This will resolve all virtuals and value keys
* @returns {Promise <Object>} A promise which will resolve to the flattened object
*/
toObject() {
var waitingOn = [];
// Calculate initial plain object from ownProperties
var obj = _.pickBy(this, (v, k) => this.hasOwnProperty(k));
// Remove all virtuals
obj = _.omit(obj, _.keys(mym.virtuals));
// Add value fields
Object.keys(mym.valuePaths).forEach(vPath => {
waitingOn = waitingOn.concat(
this.$each(vPath, (docPath, schemaPath) =>
Promise.resolve(
mym.valuePaths[vPath].call(this, this, _.get(this, docPath.slice(0, -1)), docPath, schemaPath)
).then(newVal => {
debugDetail('Set value path', docPath, '=', newVal);
_.set(obj, docPath, newVal);
})
)
);
});
return Promise.all(waitingOn).then(()=> obj);
},
/**
* Save the current Moody document back to the database
* @param {object} [patch] Additional fields to merge along with changes to the original
* @returns {Promise <Object>} A promise which will resolve when saving has completed with the server response
*/
save(patch) {
if (patch) Object.assign(this, patch);
debug('Saving document', mym.id, '/', this[mym.settings.idField]);
return this.toObject()
.then(payload => {
debugDetail('Saving document', mym.id, '/', this[mym.settings.idField], payload);
return mym.updateOneById(this[mym.settings.idField], payload);
});
},
/**
* Delete the current Moody document
* @returns {Promise} A promise which will resolve when the document has been removed
*/
delete() {
return mym.deleteOneById(this[mym.settings.idField]);
},
};
mym.virtuals = {}; // Lookup table for virtuals (corresponds with Object.defineProperties spec, but its values are calculated and just appended to the object - not via getters)
mym.valuePaths = {}; // Lookup of all keys we found that have a `value` attribute which will need setting on save, key is the field path, value is the function
// }}}
// Tidy schema {{{
/**
* Travese a schema making corrections
* @param {*} node The node to examine
* @param {array} [path] The current path, used for error reporting
* @param {number|string} [offset] The key of the parent entity - used for rewriting
* @param {boolean} [overrideSingleDef=false] If enabled do not treat the next branch as a short definition (i.e. if we find an object with a `type` property)
* @returns {*} The rewritten input node
*/
var tidySchema = (node, path = [], offset, overrideSingleDef = false) => {
if (!path.length) { // Initial setup
mym.valuePaths = {};
}
if (_.isArray(node)) {
if (node.length > 1) {
throw new Error(`Storing multi-dimentional arrays is not allowed, only collections at path ${path}`);
} else if (_.isEmpty(node[0])) {
return {
type: 'list',
list: [{type: 'string'}],
};
} else if (_.isString(node[0])) { // Shorthand specifier
return {
type: 'list',
list: [tidySchema({type: node[0]}, path, offset)],
};
} else if (_.isPlainObject(node[0])) { // Nested object of form {key: [{...}]}
return {
type: 'list',
list: [{
type: Map,
map: tidySchema(node[0], path, offset, true),
}],
};
} else {
throw new Error(`Unknown nested type at path ${path}`);
}
} else if (_.isString(node)) {
return tidySchema({type: node}, path, offset);
} else if (!overrideSingleDef && _.isPlainObject(node) && node.type && !_.isPlainObject(node.type)) { // Extended object definition
// Add path {{{
node.path = path.join('.');
// }}}
// Is there a matching my.types.translate element? {{{
var translated = my.types.translate.find(type => type.test(node.type));
if (translated) { // Found a translation
node.type = translated.type;
}
// }}}
// Check for custom schema types {{{
if (!my.types.definitions[node.type]) throw new Error(`Unknown schema type "${node.type}" for model ${mym.id} at path ${node.path}`);
if (_.isFunction(my.types.definitions[node.type])) {
my.types.definitions[node.type](node, mym, my);
} else if (_.isPlainObject(my.types.definitions[node.type])) {
_.defaults(node, my.types.definitions[node.type]);
node.type = my.types.definitions[node.type].type; // Clobber type at least so the next stage doesn't error out
}
// }}}
// Make 'required' optional (defaults to false) {{{
if (!_.has(node, 'required')) node.required = false;
// }}}
// Process 'index' property {{{
if (node.index) {
/**
* Mutate an index
* The return from this function is assumed to replace node.index entirely
* @param {*} n The input node.index value
* @returns {*} The replacement index value
*/
var mutateIndexNode = n => { // Mutate an index node and return
if (n == 'primary') { // Treat as primary key
mym.settings.idField = _.first(path.slice(-1, 2));
return {hashKey: true};
} else if (n == 'sort') { // Treat as default rangeKey
return {rangeKey: true};
} else if (n === true) { // Passthru as global
return {global: true};
} else if (n === false) { // Disable indexing
// Pass through
return false;
} else if (_.isEqual(n, {unique: true})) { // Handle as unique hash key
debug(`FIXME: No idea how to validate unique secondary indexes for model ${mym.id} at path ${n.path}`);
return true;
} else if (_.isString(n)) { // Shorthand to secondary global
return {global: true, project: true, name: n};
} else if (_.isPlainObject(n)) { // Pass to Dyanmoose
// Pass thru
return n;
} else {
debug(`Unknown index type in schema n ${node.path}`, n);
throw new Error(`Unknown index type for model ${mym.id} at path ${node.path}`);
}
};
if (_.isArray(node.index)) {
node.index = node.index.map(n => mutateIndexNode(n));
} else {
node.index = mutateIndexNode(node.index);
}
}
// }}}
// Add to valuePaths if there is a value property {{{
if (node.value) {
debugDetail('Allocate valuePath', mym.id, node.path);
mym.valuePaths[node.path] = node.value;
}
// }}}
// debug('Schema node', node);
return node;
} else if (_.isObject(node)) { // Traverse down nested object
return _.mapValues(node, (v, k) => tidySchema(v, path.concat(k)), offset+1);
}
}
// }}}
/**
* Create a single document and return it
* @param {Object} doc The document to create
* @param {Object} [options] Additional options
* @param {boolean} [options.lean=false] Bypass createing a stub MoodyDocument first, this is faster but doesnt take into account virtuals or value attributes
* @returns {Promise <Object>} A promise which will resolve with the created document
*/
mym.create = (doc, options) => {
var settings = {
lean: false,
...options,
};
debug('Create doc in table', mym.id, settings.lean ? '(lean)' : '(via MoodyDocument)');
return Promise.resolve()
.then(()=> settings.lean ? doc : new my.Document(mym, doc)) // Wrap in a MoodyDocument?
.then(data => settings.lean ? data : data.toObject()) // Flatten document into an object
.then(data => new Promise((resolve, reject) =>
mym.model.create(data, (err, created) => {
if (err) {
debug('Failed to create doc', mym.id, 'Error:', err, 'Doc:', doc);
return reject(err);
}
resolve(created);
})
));
};
/**
* Create many documents
* This function is effectively a wrapper around mym.create() with an array (but no output)
* @param {array <Object>} docs Collection of items to input
* @param {Object} [options] Additional options
* @param {number} [options.threads=my.settings.createMany.threads] How many threads to use
* @returns {Promise} A promise which will resolve when all items have been created, note that this does not return the created items
*/
mym.createMany = (docs, options) => {
var settings = {
threads: 1,
...my.settings.createMany,
...options,
};
debug('Create', docs.length, 'docs');
return my.utils.promiseThrottle(settings.threads, docs.map(doc => ()=> mym.create(doc, settings)));
};
/**
* Similar to create() but creates lots of items as quickly as possible
* This function is similar to createMany() but does NOT create a MoodyDocument first, bypassing virtuals and `value` attributes
* @param {array <Object>} docs Collection of items to input
* @param {Object} [options] Additional options
* @param {number} [options.batchSize=my.settings.createBatch.batchSize] How many documents to create at once
* @param {number} [options.threads=my.settings.createBatch.threads] How many threads to use
* @returns {Promise} A promise which will resolve when all items have been created, note that this does not return the created items
*/
mym.createBatch = (docs, options) => {
var settings = {
threads: 1,
batchSize: 3,
...my.settings.createBatch,
...options,
};
debug('Batch create', docs.length, 'docs. Will submit in batches of', settings.batchSize);
return my.utils.promiseThrottle(
settings.threads,
_.chunk(docs, settings.batchSize)
.map((docBatch, batchIndex) => ()=> new Promise((resolve, reject) => {
mym.model.batchPut(docBatch, err => {
debugDetail('Created batch', batchIndex);
if (err) return reject(err);
resolve();
});
}))
).then(()=> debug('Created', docs.length, 'docs in batch'));
};
/**
* Create a query instance which acts like a promise
* @param {Object} [query] Initial filtering criteria to apply
* @returns {MoodyQuery} The query object + Promise
*/
mym.find = query =>
new my.Query(mym)
.find(query);
/**
* Create a query instance that returns one document
* @param {Object} [query] Initial filtering criteria to apply
* @returns {MoodyQuery} The query object + Promise
*/
mym.findOne = query =>
new my.Query(mym)
.find(query)
.one();
/**
* Create a query instance that returns one document by its ID
* @param {string} id The ID of the document to return
* @returns {MoodyQuery} The query object + Promise
*/
mym.findOneById = mym.findOneByID = id => {
if (!id) throw new Error(`No ID specified for moody.${mym.id}.findOneById()`);
return new my.Query(mym)
.find({[mym.settings.idField]: id})
.one();
};
/**
* Create a query instance that searches for a single item by its ID and updates it
* @param {id} string ID of the document to update
* @param {Object} patch Patch to apply
* @returns {MoodyQuery} The query object + Promise
*/
mym.updateOneById = mym.updateOneByID = (id, patch) => new Promise((resolve, reject) => {
debug('Update', id, 'with patch', patch);
mym.model.update(
{[mym.settings.idField]: id},
{$PUT: patch},
(err, doc) => {
if (err) return reject(err);
resolve(doc);
}
)
});
/**
* Create a query instance that searches for a single item and updates it
* @param {Object} query Query used to find the document
* @param {Object} patch Patch to apply
* @returns {MoodyQuery} The query object + Promise
*/
mym.updateOne = (query, patch) =>
new my.Query(mym)
.find(query)
.one()
.update(patch);
/**
* Create a query instance that searches for a for items by a query and updates them
* @param {id} query Query to use when searching
* @param {Object} update Patch to apply
* @returns {MoodyQuery} The query object + Promise
*/
mym.updateMany = (query, patch) =>
new my.Query(mym)
.find(query)
.update(patch);
/**
* Create a query instance in count mode with an optional filter
* @param {Object} [query] Optional query to use
* @returns {MoodyQuery} The query object + Promise
*/
mym.count = query =>
new my.Query(mym)
.count(query);
/**
* Remove a single document by its primary key
* @param {string} id The ID of the document to remove
*/
mym.deleteOneById = mym.deleteOneByID = (id, patch) => new Promise((resolve, reject) => {
debug('Delete', id);
mym.model.delete(id, (err, doc) => {
if (err) return reject(e);
resolve(doc);
})
});
/**
* Remove the first matching document
* @param {Object} query Query to remove documents by, this may be an empty object
*/
mym.deleteOne = query =>
new my.Query(mym)
.find(query)
.one()
.delete();
/**
* Remove all matching documents
* @param {Object} query Query to remove documents by, this may be an empty object
*/
mym.deleteMany = query =>
new my.Query(mym)
.find(query)
.delete();
/**
* Utility function to quickly load a JSON / JS file into a model
* @param {Object|string} input Either a JS object or a file which can be loaded via `require()`
* @returns {Promise} A promise which will resolve when the input data has been processed
*/
mym.loadData = input => Promise.resolve()
.then(()=> {
if (_.isString(input)) {
debug('Load data from', input);
return require(input);
} else {
return input;
}
})
.then(res => {
if (!Array.isArray(res)) throw new Error('Input data must be a collection');
debug('Going to batch load', res.length, 'documents');
return res
})
.then(res => mym.createBatch(res));
/**
* Destroy the table, removing all data
* This function is automatically called if `settings.deleteExisting` is true
* @param {Object} [options] Additional options to pass
* @param {boolean} [options.ignoreNotExist=true] Don't throw if the table doesn't already exist
* @returns {Promise} A promise which will resolve when the table has been removed
*/
mym.dropTable = options => new Promise((resolve, reject) => {
var settings = {
ignoreNotExist: true,
...options,
};
// my.dynamoose.dynamoDB.deleteTable({TableName: id}, err => err ? resolve() : reject())
my.dynamoose.dynamoDB.deleteTable({TableName: id}, err => {
if (err) {
if (settings.ignoreNotExist && err.code == 'ResourceNotFoundException') return resolve();
debug('Err on drop', err);
reject(err);
} else {
resolve();
}
});
});
/**
* Create the intial table schema
* @returns {Promise} A promise which will resolve when the table has been created
*/
mym.createTable = ()=> {
debug(`Create table "${mym.id}"`);
var schema = tidySchema(mym.schema);
debugDetail(`Create table "${mym.id}" using schema`, schema);
return mym.model = my.dynamoose.model(mym.id, schema, {
...mym.my.settings.tables,
});
};
/**
* Declare a static method against a model
* This is really just a mixin method to glue functios to this object instance
* @param {string} name The function name to add
* @param {function} func The function to add
* @returns {MoodyModel} This chainable model
*/
mym.static = (name, func) => {
if (mym[name]) throw new Error(`Unable to add method "${name}" as it is already declared`);
mym[name] = func;
debug(`Alloc static method "${name}" to ${mym.id}`)
return mym;
};
/**
* Declare a method against a document return
* This extends the prototype object when documents are returned
* @param {string} name The function name to add
* @param {function} func The function to add
* @returns {MoodyModel} This chainable model
*/
mym.method = (name, func) => {
if (mym.prototype[name]) throw new Error(`Unable to add document prototype method "${name}" as it is already declared`);
mym.prototype[name] = func;
debug(`Alloc doc prototype method "${name}" to ${mym.id}`)
return mym;
};
/**
* Declare a virtual field against a document
* A virtual is a getter / setter which is omited from the output
* @param {string} name The function name to add
* @param {function} [getFunc] The function to use as a getter
* @param {function} [setFunc] The function to use as a setter
* @returns {MoodyModel} This chainable model
*/
mym.virtual = (name, getFunc, setFunc) => {
mym.virtuals[name] = {
get: getFunc,
set: setFunc,
};
debug(`Alloc virtual method "${name}" to ${mym.id}`)
return mym;
};
/**
* Ask the remote database to describe a table
* The response of this function is the raw AWS spec
* @url https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#describeTable-property
* @param {Object} [options] Additional options to pass
* @param {boolean} [options.cached=true] Use a cached description if we have one
* @returns {Promise <Object>} A promise which will resolve with the table definition
*/
mym.describe = options => new Promise((resolve, reject) => {
var settings = {
cached: true,
...options,
};
if (settings.cached && mym.describeCache) return resolve(mym.describeCache);
debug(`Describe table "${id}"`);
my.dynamoose.dynamoDB.describeTable({TableName: id}, (err, spec) => {
if (err) return reject(err);
// Compute moodyIndexLookup {{{
var evalKeySchema = (schema, patch) => ({
...patch,
filters: schema.filter(s => s.KeyType == 'HASH').map(i => i.AttributeName),
sorts: schema.filter(s => s.KeyType == 'RANGE').map(i => i.AttributeName),
});
debug('RAW SPEC', JSON.stringify(spec, null, '\t'));
spec.moodyIndexLookup = [].concat(
[evalKeySchema(spec.Table.KeySchema, {type: 'partitionKey', index: 'partitionKey', indexN: spec.Table.KeySchema.filter(i => i.KeyType == 'HASH')[0].AttributeName})],
(spec.Table.LocalSecondaryIndexes || []).map(i => evalKeySchema(i.KeySchema, {type: 'localSecondary', index: i.IndexName})),
(spec.Table.GlobalSecondaryIndexes || []).map(i => evalKeySchema(i.KeySchema, {type: 'globalSecondary', index: i.IndexName})),
);
// }}}
if (settings.cached) mym.describeCache = spec;
resolve(spec);
});
});
/**
* Last return of mym.describe, used to return the cached value
* @var {Object}
*/
mym.describeCache;
/**
* Alternate way to set up a ReST server
* @param {Object} [options] Options to create the ReST server
*/
mym.serve = options => new my.RestServer(mym, options);
my.models[mym.id] = this;
return Promise.resolve()
.then(()=> mym.settings.deleteExisting && mym.dropTable())
.then(()=> mym.createTable())
.then(()=> mym);
};