@apigrate/mysqlutils
Version:
An easy-to-use Promise-based MySQL DAO implementation.
599 lines (545 loc) • 20 kB
JavaScript
/**
* @module A set of ExpressJS middleware functions that provide convenient
* RESTful API handling.
*
* Note, usage requires the `express` peer dependency.
*/
const _ = require('lodash');
/**
Handles a GET by ID request to query an entity by id from the database.
@returns response handled as follows
- HTTP 400 if no dbInstructions or id is set
- HTTP 404 if the entity is not found
- HTTP 200 with the entity when found.
*/
async function fetchById(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || !dbi.id) {
res.status(400).json({message:'Unable to get data.',error:'Missing id.'});
return;
}
let singleEntity = await dbi.dao.get(dbi.id);
if (_.isEmpty(singleEntity)) {
res.status(404).end();
return;
}
if(singleEntity && dbi.omit && dbi.omit.length > 0){
for(let prop in singleEntity){
if(dbi.omit.indexOf(prop)>=0){
delete singleEntity[prop]
}
}
}
res.status(200).json(singleEntity);
return;
} catch (ex) {
next(ex);
}
}
/**
Handles a request to query an entity by a criteria
and return a single matching entity.
@returns response handled as follows
- HTTP 400 if no dbInstructions or no query
- HTTP 404 if the entity is not found
- HTTP 200 with the entity when found.
*/
async function fetchOne(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || _.isEmpty(dbi.query)) {
res.status(400).json({message:'Unable to get data.',error:'Missing query.'});
return;
}
let singleEntity = await dbi.dao.one(dbi.query);
if (_.isEmpty(singleEntity)) {
res.status(404).end();
return;
}
res.status(200).json(singleEntity);
return;
} catch (ex) {
next(ex);
}
}
/**
Handles a request to query entities by a criteria where many entities can be returned.
Expects a `res.locals.dbInstructions` entity with the following properties:
@property {object} dao the data access object
@property {object} query query object used to match criteria for a simple implied WHERE clause. Either `query` or `criteria` is required.
@property {object} criteria a criteria helper object for complex queries. Either `query` or `criteria` is required.
@property {string} criteria.whereClause a parameterized SQL where clause without the 'WHERE'.
@property {string} criteria.parms the parameter array for the whereClause .
@property {object} query_options the query options object
@property {array} query_options.columns (optional) columns to include in the response. If omitted, all are returned.
@property {number} query_options.limit the number of rows to limit being returned
@property {array} query_options.orderBy the order by criteria as an array.
@property {boolean} with_total (optional, default false) when true, an additional "total" property is added to the response payload
representing the total number of entities in the database that matched the query-regardless of limit parameters.
@returns response object containing an array of matching entities under the DAO entity plural property name. If the `with_total` instruction
was true, the `total` property will also be set.
The HTTP response behavior is handled as follows
- HTTP 400 if no dbInstructions or no query/criteria, query_options, or query_options.limit, or
an invalid query_options.limit (i.e > 10000 entities or <= 0).
- HTTP 200 with the array of results (or an empty array if no matches).
*/
async function fetchMany(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi)) {
res.status(400).json({message:'Unable to get data.',error:'Missing payload.'});
return;
}
if (_.isEmpty(dbi.query) && _.isEmpty(dbi.criteria) ){
//A "select ALL" is forbidden. A limit criterion must be provided.
if( _.isEmpty(dbi.query_options)
|| !dbi.query_options.limit
|| (parseInt(dbi.query_options.limit) <= 0 || parseInt(dbi.query_options.limit) > 10000)){
res.status(400).json({ message:'Unable to get data.',error: "If no query criteria are provided, a valid limit parameter must be provided." });
return;
}
dbi.query = {};
}
let result = {};
if (dbi.query){
if (dbi.with_total){
result.total = await dbi.dao.count(dbi.query);
if(result.total === 0){
//Don't bother with the full query and return immediately.
result[dbi.dao.plural] = [];
res.status(200).json(result);
return;
}
}
let multipleEntities = await dbi.dao.find(dbi.query, dbi.query_options);
result[dbi.dao.plural] = multipleEntities;
res.status(200).json(result);
return;
} else if (dbi.criteria){
if (dbi.with_total){
let temp = await dbi.dao.sqlCommand(`SELECT count(*) AS count FROM ${dbi.dao.table} WHERE ${dbi.criteria.whereClause}`, dbi.criteria.parms);
result.total = temp[0].count;
if(result.total === 0){
//Don't bother with the full query and return immediately.
result[dbi.dao.plural] = [];
res.status(200).json(result);
return;
}
}
let multipleEntities = await dbi.dao.selectWhere(dbi.criteria.whereClause, dbi.criteria.parms, dbi.query_options);
result[dbi.dao.plural] = multipleEntities;
res.status(200).json(result);
return;
}
res.status(400).json({ message:'Unable to get data.',error: "Insufficient query criteria were provided." });
return;
} catch (ex) {
next(ex);
}
}
/**
Handles a request to query a count of entities by a criteria.
@returns response handled as follows
- HTTP 400 if no dbInstructions or no query, query_options, or query_options.limit, or
an invalid query_options.limit (i.e > 10000 entities or <= 0).
- HTTP 200 with the count of results or 0 if no matches.
*/
async function fetchCount(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi)) {
res.status(400).json({message:'Unable to get data.',error:'Missing payload.'});
return;
}
let result = await dbi.dao.count(dbi.query);
res.status(200).json(result);
return;
} catch (ex) {
next(ex);
}
}
/**
Handles a request to create an entity.
@returns response handled as follows
- HTTP 400 if no dbInstructions or no toSave entity
- HTTP 200 with the created entity.
*/
async function create(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || _.isEmpty(dbi.toSave)) {
res.status(400).json({message:'Unable to create.',error:'Missing payload.'});
return;
}
let result = await dbi.dao.create(dbi.toSave);
//Support omitting properties from being returned to the consumer.
if(result && dbi.omit && dbi.omit.length > 0){
for(let prop in result){
if(dbi.omit.indexOf(prop)>=0){
delete result[prop]
}
}
}
res.status(200).json(result);
return;
} catch (ex) {
next(ex);
}
}
/**
Handles a request to upsert (create or update) an entity.
@returns response handled as follows
- HTTP 400 if no dbInstructions or no toSave entity
- HTTP 200 with the created/updated entity.
*/
async function save(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || _.isEmpty(dbi.toSave)) {
res.status(400).json({message:'Unable to save.',error:'Missing payload.'});
return;
}
let result = await dbi.dao.save(dbi.toSave);
//Support omitting properties from being returned to the consumer.
if(result && dbi.omit && dbi.omit.length > 0){
for(let prop in result){
if(dbi.omit.indexOf(prop)>=0){
delete result[prop]
}
}
}
res.status(200).json(result);
return;
} catch (ex) {
next(ex);
}
}
/**
* Handles a request to replace an array of entities with anew
* array of entities. This operation does NOT delete all old
* insert all new. Instead, it determines which existing
* entries are different
*
* expected res.locals.dbInstructions:
* @example {
* dao: {} // the dao you're working with.
* query: {} // the query to find the entities
* query_options: {} // (optional) any query options
* toSave: [] // the new array values that should replace the results of the query
* comparison: function(v){ return v.product_id} // comparison function that identifies new vs. existing records (parameter is the value of the array)
* }
*/
async function saveAll(req, res, next){
try{
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi)) {
res.status(400).json({message:'Unable to save.',error:'Missing payload.'});
return;
}
if (_.isEmpty(dbi.query) &&
(_.isEmpty(dbi.query_options)
|| _.isEmpty(dbi.query_options.limit)
|| (dbi.query_options.limit * 1 <= 0 || dbi.query_options.limit * 1 > 10000))) {
res.status(400).json({ message:'Unable to save.',error: "If no query criteria are provided, a valid limit parameter must be provided." });
return;
}
//Get array of existing entities
let existing = await dbi.dao.find(dbi.query, dbi.query_options);
//Determine what should be added or removed.
let shared = _.intersectionBy(existing, dbi.toSave, dbi.comparison );
let delete_these = _.xorBy(existing, shared, dbi.comparison );
let create_these = _.xorBy(dbi.toSave, shared, dbi.comparison );
//Deletes
console.log(`${delete_these.length} to delete.`)
let deleted = await Promise.all( delete_these.map( function(entity){ return dbi.dao.deleteOne(entity); } ) );
//Creates
console.log(`${create_these.length} to create.`)
let created = await Promise.all( create_these.map( function(entity){ return dbi.dao.create(entity); } ) );
created.forEach((result, idx)=>{
create_these[idx].id = result.id;
// no omit support yet since we are only splicing an id, not overwriting the entire result.
});
res.status(200).json(_.concat(shared, create_these));//return the objects which exist
} catch (ex){
next(ex);
}
}
/**
* Handles a request to mass-update an array of entities. Each entity must have a primary key id
* property present. Entities that do not match existing database rows are ignored.
*
* expected res.locals.dbInstructions:
* @example {
* dao: {} // the dao you're working with.
* toSave: [] // the new array values that should be used to update the entities in the db
* }
*
* @since 4.3.0
*/
async function updateAll(req, res, next){
try{
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || _.isEmpty(dbi.toSave)) {
res.status(400).json({message:'Unable to save.',error:'Missing payload.'});
return;
}
let updated = await Promise.all( dbi.toSave.map( function(entity){ return dbi.dao.update(entity); } ) );
res.status(200).json(updated);//return the objects which exist
} catch (ex){
next(ex);
}
}
/**
Handle a request to update an entity. (The entity itself must include its db identifier).
@returns response handled as follows
- HTTP 400 if no dbInstructions or no toUpdate entity
- HTTP 410 if there no entity was found to update (nothing is returned).
- HTTP 200 with the updated entity.
*/
async function updateById(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || _.isEmpty(dbi.toUpdate)) {
res.status(400).json({message:'Unable to update.',error:'Missing payload.'});
return;
}
let result = await dbi.dao.update(dbi.toUpdate);
if (result && result._affectedRows === 0) {
res.status(410).end();
return;
}
//Support omitting properties from being returned to the consumer.
if(result && dbi.omit && dbi.omit.length > 0){
for(let prop in result){
if(dbi.omit.indexOf(prop)>=0){
delete result[prop]
}
}
}
res.status(200).json(result);
return;
} catch (ex) {
next(ex);
}
}
/**
Handle a request to update multiple entities that match a criteria.
@returns response handled as follows
- HTTP 400 if no dbInstructions or no toUpdate entity
- HTTP 200 with an object containing the _affectedRows row count of what was updated (even if _affectedRows = 0).
*/
async function updateMatching(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || _.isEmpty(dbi.toUpdate)) {
res.status(400).json({message:'Unable to update.',error:'Missing payload.'});
return;
}
if (_.isEmpty(dbi.query)) {
res.status(400).json({ message:'Unable to update.',error: "Update criteria are required." });
return;
}
let result = await dbi.dao.updateMatching(dbi.toUpdate, dbi.query);
res.status(200).json(result);
return;
} catch (ex) {
next(ex);
}
}
/**
Handle a request to delete an entity by id.
@returns response handled as follows
- HTTP 400 if no dbInstructions or no id was given
- HTTP 410 if there no entity was found to delete (nothing is returned).
- HTTP 200 with an object indicating _affectedRows.
*/
async function deleteById(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || !dbi.id) {
res.status(400).json({message:'Unable to delete.',error:'Missing id.'});
return;
}
let result = await dbi.dao.delete(dbi.id);
if (result && result._affectedRows === 0) {
res.status(410).end();
return;
}
res.status(200).json(result);
return;
} catch (ex) {
next(ex);
}
}
/**
Handle a request to delete many entities that match criteria matching the
toDelete template entity.
@returns response handled as follows
- HTTP 400 if no dbInstructions or no toDelete template was given
- HTTP 410 if there no entities were found to delete (nothing is returned).
- HTTP 200 with an object indicating _affectedRows.
*/
async function deleteMatching(req, res, next) {
try {
let dbi = res.locals.dbInstructions;
if (_.isEmpty(dbi) || _.isEmpty(dbi.toDelete)) {
res.status(400).json({message:'Unable to delete.',error:'Missing prototype.'});
return;
}
let result = await dbi.dao.deleteMatching(dbi.toDelete);
if (result && result._affectedRows === 0) {
res.status(410).end();
return;
}
res.status(200).json(result);
return;
} catch (ex) {
next(ex);
}
}
/**
* Parses the query instructions for a query from the expressjs request.
* @see parseQueryOptionsFromObject
*/
function parseQueryOptions(req, allowed_query_fields, default_orderby, default_limit){
return parseQueryOptionsFromObject(req.query, allowed_query_fields, default_orderby, default_limit)
}
/**
* Parses the query instructions for an object.
* @param {object} obj any generic object containing the query info
* @param {array} allowed_query_fields allowable query fields
* @param {array} default_orderby order by these fields by default (default ['+id'])
* @param {number} default_limit max records returned (default: 10000)
* @returns {object}
* @example {
* query: { name: 'platypus', type: 'mammal'}
* query_options: {limit: 10000, offset: 0, order_by: [...]}
* }
*/
function parseQueryOptionsFromObject(obj, allowed_query_fields, default_orderby, default_limit){
let query = obj;
let query_options = {
limit: default_limit || 10000,
orderBy: default_orderby || ['+id']
};
for (key in query) {
//Valid search fields
if (allowed_query_fields.indexOf(key) >= 0) continue;
if (key === 'limit') {
query_options.limit = query[key];
}
if (key === 'offset') {
query_options.offset = query[key];
}
if (key === 'order_by') {
if(_.isArray(query[key])){
query_options.orderBy = query[key];
} else {
query_options.orderBy = query[key].split(',');
}
}
delete query[key];
}
return { query, query_options };
}
/**
* Middleware error handler for API requests. If an error reaches this handler, it will be returned with an
* HTTP 500 status with a JSON content error.
*
* Addititionally, the handler looks for sqlState property on the error and obfuscates the error message
* to avoid broadcasting sensitive internal db state information to the API consumer.
*
* @example <caption>returned response</caption>
* {
* message: "Unexpected error.",
* error: errMessage
* }
*
* @param {object} err the error
* @param {object} req the request
* @param {object} res the response
* @param {function} next (never invoked)
*/
async function handleApiErrors(err, req, res, next){
console.error(err);
let errMessage = err.message;
if(err.sqlState){
errMessage = 'Invalid data or invalid data relationship.';
}
res.status(500).json({
message: "Unexpected error.",
error: errMessage
});
}
module.exports.fetchById = fetchById;
module.exports.fetchCount = fetchCount;
module.exports.fetchOne = fetchOne;
module.exports.fetchMany = fetchMany;
module.exports.create = create;
module.exports.updateById = updateById;
module.exports.updateMatching = updateMatching;
module.exports.updateAll = updateAll;
module.exports.save = save;
module.exports.saveAll = saveAll;
module.exports.deleteById = deleteById;
module.exports.deleteMatching = deleteMatching;
module.exports.handleApiErrors = handleApiErrors;
module.exports.parseQueryOptions = parseQueryOptions;
module.exports.parseQueryOptionsFromObject = parseQueryOptionsFromObject;
/**
* A class that encapsulates DB instructions to be used.
* by the middleware functions.
*
* You instantiate DbInstruction in your upstream router methods.
* Then, add the object to the res.locals property and invoke
* next() in your router. The downstream api functions
* will use these instructions to make the appropriate
* database query/command and return results consistently.
*
*/
class DbInstructions{
/**
*
* @param {function} opts.comparison a comparison function used by
* the `saveAll` middleware function. The comparison identifies
* new vs. existing records. It has a single parameter, which is
* the value received on the retrieval query or the value of the
* provided `toSave` parameter.
*
* @param {Dao} opts.dao (required) a Dao object providing data access
*
* @param {any} opts.id identifier for specifying records in the `fetchById`
* and `deleteById` middleware functions.
*
* @param {array} opts.omit an array of column names to omit from being
* returned in a query, insert, or update response.
*
* @param {string} query a sql query statement for use in the `fetch*` and
* `saveAll` middleware functions
*
* @param {object} query_options a query options object containing
* `offset`, `limit`, and/or `orderBy` properties for the query.
*
* @param {object} toDelete an object to be deleted on a deletion-related
* middleware functions
*
* @param {object} toUpdate an object to be updated on updated-related
* middleware functions
*
* @param {object} toSave an object to be inserted (or updated) on create/save-related
* middleware functions.
*/
constructor(opts){
this.comparison = opts ? opts.comparison : null;
this.dao = opts ? opts.dao : null;
this.id = opts ? opts.id : null;
this.omit = opts ? opts.omit : null;
this.query = opts ? opts.query : null;
this.query_options = opts ? opts.query_options : null;
this.toDelete = opts ? opts.toDelete : null;
this.toSave = opts ? opts.toSave : null;
this.toUpdate = opts ? opts.toUpdate : null;
}
}
exports.DbInstructions = DbInstructions;