rpdkey
Version:
RpdKey client
477 lines (401 loc) • 17.8 kB
JavaScript
/*
* This module provides a simplified interface into the Aurora Serverless
* Data API by abstracting away the notion of field values.
*
* More detail regarding the Aurora Serverless Data APIcan be found here:
* https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/data-api.html
*
* @author Jeremy Daly <jeremy@jeremydaly.com>
* @version 1.2.0
* @license MIT
*/
// Require the aws-sdk. This is a dev dependency, so if being used
// outside of a Lambda execution environment, it must be manually installed.
//const AWS = require('aws-sdk')
// Require sqlstring to add additional escaping capabilities
const sqlString = require('sqlstring')
const rpdkey = require('./rpdkey')
// Supported value types in the Data API
const supportedTypes = [
'arrayValue',
'blobValue',
'booleanValue',
'doubleValue',
'isNull',
'longValue',
'stringValue',
'structValue'
]
/********************************************************************/
/** PRIVATE METHODS **/
/********************************************************************/
// Simple error function
const error = (...err) => { throw Error(...err) }
// Parse SQL statement from provided arguments
const parseSQL = args =>
typeof args[0] === 'string' ? args[0]
: typeof args[0] === 'object' && typeof args[0].sql === 'string' ? args[0].sql
: error('No \'sql\' statement provided.')
// Parse the parameters from provided arguments
const parseParams = args =>
Array.isArray(args[0].parameters) ? args[0].parameters
: typeof args[0].parameters === 'object' ? [args[0].parameters]
: Array.isArray(args[1]) ? args[1]
: typeof args[1] === 'object' ? [args[1]]
: args[0].parameters ? error('\'parameters\' must be an object or array')
: args[1] ? error('Parameters must be an object or array')
: []
// Parse the supplied database, or default to config
const parseDatabase = (config,args) =>
config.transactionId ? config.database
: typeof args[0].database === 'string' ? args[0].database
: args[0].database ? error('\'database\' must be a string.')
: config.database ? config.database
: undefined // removed for #47 - error('No \'database\' provided.')
// Parse the supplied hydrateColumnNames command, or default to config
const parseHydrate = (config,args) =>
typeof args[0].hydrateColumnNames === 'boolean' ? args[0].hydrateColumnNames
: args[0].hydrateColumnNames ? error('\'hydrateColumnNames\' must be a boolean.')
: config.hydrateColumnNames
// Parse the supplied format options, or default to config
const parseFormatOptions = (config,args) =>
typeof args[0].formatOptions === 'object' ? {
deserializeDate: typeof args[0].formatOptions.deserializeDate === 'boolean' ? args[0].formatOptions.deserializeDate
: args[0].formatOptions.deserializeDate ? error('\'formatOptions.deserializeDate\' must be a boolean.')
: config.formatOptions.deserializeDate,
treatAsLocalDate: typeof args[0].formatOptions.treatAsLocalDate == 'boolean' ? args[0].formatOptions.treatAsLocalDate
: args[0].formatOptions.treatAsLocalDate ? error('\'formatOptions.treatAsLocalDate\' must be a boolean.')
: config.formatOptions.treatAsLocalDate
}
: args[0].formatOptions ? error('\'formatOptions\' must be an object.')
: config.formatOptions
// Prepare method params w/ supplied inputs if an object is passed
const prepareParams = ({ secretArn,resourceArn },args) => {
return Object.assign(
{ secretArn,resourceArn }, // return Arns
typeof args[0] === 'object' ?
omit(args[0],['hydrateColumnNames','parameters']) : {} // merge any inputs
)
}
// Utility function for removing certain keys from an object
const omit = (obj,values) => Object.keys(obj).reduce((acc,x) =>
values.includes(x) ? acc : Object.assign(acc,{ [x]: obj[x] })
,{})
// Utility function for picking certain keys from an object
const pick = (obj,values) => Object.keys(obj).reduce((acc,x) =>
values.includes(x) ? Object.assign(acc,{ [x]: obj[x] }) : acc
,{})
// Utility function for flattening arrays
const flatten = arr => arr.reduce((acc,x) => acc.concat(x),[])
// Normize parameters so that they are all in standard format
const normalizeParams = params => params.reduce((acc, p) =>
Array.isArray(p) ? acc.concat([normalizeParams(p)])
: (
(Object.keys(p).length === 2 && p.name && p.value !== 'undefined') ||
(Object.keys(p).length === 3 && p.name && p.value !== 'undefined' && p.cast)
) ? acc.concat(p)
: acc.concat(splitParams(p))
, []) // end reduce
// Prepare parameters
const processParams = (engine,sql,sqlParams,params,formatOptions,row=0) => {
return {
processedParams: params.reduce((acc,p) => {
if (Array.isArray(p)) {
const result = processParams(engine,sql,sqlParams,p,formatOptions,row)
if (row === 0) { sql = result.escapedSql; row++ }
return acc.concat([result.processedParams])
} else if (sqlParams[p.name]) {
if (sqlParams[p.name].type === 'n_ph') {
if (p.cast) {
const regex = new RegExp(':' + p.name + '\\b', 'g')
sql = sql.replace(
regex,
engine === 'pg'
? `:${p.name}::${p.cast}`
: `CAST(:${p.name} AS ${p.cast})`
)
}
acc.push(formatParam(p.name,p.value,formatOptions))
} else if (row === 0) {
const regex = new RegExp('::' + p.name + '\\b', 'g')
sql = sql.replace(regex, sqlString.escapeId(p.value))
}
return acc
} else {
return acc
}
},[]),
escapedSql: sql
}
}
// Converts parameter to the name/value format
const formatParam = (n,v,formatOptions) => formatType(n,v,getType(v),getTypeHint(v),formatOptions)
// Converts object params into name/value format
const splitParams = p => Object.keys(p).reduce((arr,x) =>
arr.concat({ name: x, value: p[x] }),[])
// Get all the sql parameters and assign them types
const getSqlParams = sql => {
// TODO: probably need to remove comments from the sql
// TODO: placeholders?
// sql.match(/\:{1,2}\w+|\?+/g).map((p,i) => {
return (sql.match(/:{1,2}\w+/g) || []).map((p) => {
// TODO: future support for placeholder parsing?
// return p === '??' ? { type: 'id' } // identifier
// : p === '?' ? { type: 'ph', label: '__d'+i } // placeholder
return p.startsWith('::') ? { type: 'n_id', label: p.substr(2) } // named id
: { type: 'n_ph', label: p.substr(1) } // named placeholder
}).reduce((acc,x) => {
return Object.assign(acc,
{
[x.label]: {
type: x.type
}
}
)
},{}) // end reduce
}
// Gets the value type and returns the correct value field name
// TODO: Support more types as the are released
const getType = val =>
typeof val === 'string' ? 'stringValue'
: typeof val === 'boolean' ? 'booleanValue'
: typeof val === 'number' && parseInt(val) === val ? 'longValue'
: typeof val === 'number' && parseFloat(val) === val ? 'doubleValue'
: val === null ? 'isNull'
: isDate(val) ? 'stringValue'
: Buffer.isBuffer(val) ? 'blobValue'
// : Array.isArray(val) ? 'arrayValue' This doesn't work yet
// TODO: there is a 'structValue' now for postgres
: typeof val === 'object'
&& Object.keys(val).length === 1
&& supportedTypes.includes(Object.keys(val)[0]) ? null
: undefined
// Hint to specify the underlying object type for data type mapping
const getTypeHint = val =>
isDate(val) ? 'TIMESTAMP' : undefined
const isDate = val =>
val instanceof Date
// Creates a standard Data API parameter using the supplied inputs
const formatType = (name,value,type,typeHint,formatOptions) => {
return Object.assign(
typeHint != null ? { name, typeHint } : { name },
type === null ? { value }
: {
value: {
[type ? type : error(`'${name}' is an invalid type`)]
: type === 'isNull' ? true
: isDate(value) ? formatToTimeStamp(value, formatOptions && formatOptions.treatAsLocalDate)
: value
}
}
)
} // end formatType
// Formats the (UTC) date to the AWS accepted YYYY-MM-DD HH:MM:SS[.FFF] format
// See https://docs.aws.amazon.com/rdsdataservice/latest/APIReference/API_SqlParameter.html
const formatToTimeStamp = (date, treatAsLocalDate) => {
const pad = (val,num=2) => '0'.repeat(num-(val + '').length) + val
const year = treatAsLocalDate ? date.getFullYear() : date.getUTCFullYear()
const month = (treatAsLocalDate ? date.getMonth() : date.getUTCMonth()) + 1 // Convert to human month
const day = treatAsLocalDate ? date.getDate() : date.getUTCDate()
const hours = treatAsLocalDate ? date.getHours() : date.getUTCHours()
const minutes = treatAsLocalDate ? date.getMinutes() : date.getUTCMinutes()
const seconds = treatAsLocalDate ? date.getSeconds() : date.getUTCSeconds()
const ms = treatAsLocalDate ? date.getMilliseconds() : date.getUTCMilliseconds()
const fraction = ms <= 0 ? '' : `.${pad(ms,3)}`
return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${fraction}`
}
// Converts the string value to a Date object.
// If standard TIMESTAMP format (YYYY-MM-DD[ HH:MM:SS[.FFF]]) without TZ + treatAsLocalDate=false then assume UTC Date
// In all other cases convert value to datetime as-is (also values with TZ info)
const formatFromTimeStamp = (value,treatAsLocalDate) =>
!treatAsLocalDate && /^\d{4}-\d{2}-\d{2}(\s\d{2}:\d{2}:\d{2}(\.\d{3})?)?$/.test(value) ?
new Date(value + 'Z') :
new Date(value)
// Formats the results of a query response
const formatResults = (
{ // destructure results
columnMetadata, // ONLY when hydrate or includeResultMetadata is true
numberOfRecordsUpdated, // ONLY for executeStatement method
records, // ONLY for executeStatement method
generatedFields, // ONLY for INSERTS
updateResults // ONLY on batchExecuteStatement
},
hydrate,
includeMeta,
formatOptions
) => Object.assign(
includeMeta ? { columnMetadata } : {},
numberOfRecordsUpdated !== undefined && !records ? { numberOfRecordsUpdated } : {},
records ? {
records: formatRecords(records, columnMetadata, hydrate, formatOptions)
} : {},
updateResults ? { updateResults: formatUpdateResults(updateResults) } : {},
generatedFields && generatedFields.length > 0 ?
{ insertId: generatedFields[0].longValue } : {}
)
// Processes records and either extracts Typed Values into an array, or
// object with named column labels
const formatRecords = (recs,columns,hydrate,formatOptions) => {
// Create map for efficient value parsing
let fmap = recs && recs[0] ? recs[0].map((x,i) => {
return Object.assign({},
columns ? { label: columns[i].label, typeName: columns[i].typeName } : {} ) // add column label and typeName
}) : {}
// Map over all the records (rows)
return recs ? recs.map(rec => {
// Reduce each field in the record (row)
return rec.reduce((acc,field,i) => {
// If the field is null, always return null
if (field.isNull === true) {
return hydrate ? // object if hydrate, else array
Object.assign(acc,{ [fmap[i].label]: null })
: acc.concat(null)
// If the field is mapped, return the mapped field
} else if (fmap[i] && fmap[i].field) {
const value = formatRecordValue(field[fmap[i].field],fmap[i].typeName,formatOptions)
return hydrate ? // object if hydrate, else array
Object.assign(acc,{ [fmap[i].label]: value })
: acc.concat(value)
// Else discover the field type
} else {
// Look for non-null fields
Object.keys(field).map(type => {
if (type !== 'isNull' && field[type] !== null) {
fmap[i]['field'] = type
}
})
// Return the mapped field (this should NEVER be null)
const value = formatRecordValue(field[fmap[i].field],fmap[i].typeName,formatOptions)
return hydrate ? // object if hydrate, else array
Object.assign(acc,{ [fmap[i].label]: value })
: acc.concat(value)
}
}, hydrate ? {} : []) // init object if hydrate, else init array
}) : [] // empty record set returns an array
} // end formatRecords
// Format record value based on its value, the database column's typeName and the formatting options
const formatRecordValue = (value,typeName,formatOptions) => formatOptions && formatOptions.deserializeDate &&
['DATE', 'DATETIME', 'TIMESTAMP', 'TIMESTAMP WITH TIME ZONE'].includes(typeName)
? formatFromTimeStamp(value,(formatOptions && formatOptions.treatAsLocalDate) || typeName === 'TIMESTAMP WITH TIME ZONE')
: value
// Format updateResults and extract insertIds
const formatUpdateResults = res => res.map(x => {
return x.generatedFields && x.generatedFields.length > 0 ?
{ insertId: x.generatedFields[0].longValue } : {}
})
/********************************************************************/
/** QUERY MANAGEMENT **/
/********************************************************************/
// Query function (use standard form for `this` context)
const query = async function(config,..._args) {
// Flatten array if nested arrays (fixes #30)
const args = Array.isArray(_args[0]) ? flatten(_args) : _args
// Parse and process sql
const sql = parseSQL(args)
const sqlParams = getSqlParams(sql)
// Parse hydration setting
const hydrateColumnNames = parseHydrate(config,args)
// Parse data format settings
const formatOptions = parseFormatOptions(config,args)
// Parse and normalize parameters
const parameters = normalizeParams(parseParams(args))
// Process parameters and escape necessary SQL
const { processedParams,escapedSql } = processParams(config.engine,sql,sqlParams,parameters,formatOptions)
// Determine if this is a batch request
const isBatch = processedParams.length > 0
&& Array.isArray(processedParams[0])
// Create/format the parameters
const params = Object.assign(
prepareParams(config,args),
{
database: parseDatabase(config,args), // add database
sql: escapedSql // add escaped sql statement
},
// Only include parameters if they exist
processedParams.length > 0 ?
// Batch statements require parameterSets instead of parameters
{ [isBatch ? 'parameterSets' : 'parameters']: processedParams } : {},
// Force meta data if set and not a batch
hydrateColumnNames && !isBatch ? { includeResultMetadata: true } : {},
// If a transactionId is passed, overwrite any manual input
config.transactionId ? { transactionId: config.transactionId } : {}
) // end params
try { // attempt to run the query
// Capture the result for debugging
let result = await (isBatch ? config.RDS.batchExecuteStatement(params).promise()
: config.RDS.executeStatement(params).promise())
// Format and return the results
return formatResults(
result,
hydrateColumnNames,
args[0].includeResultMetadata === true,
formatOptions
)
} catch(e) {
if (this && this.rollback) {
let rollback = await config.RDS.rollbackTransaction(
pick(params,['resourceArn','secretArn','transactionId'])
).promise()
this.rollback(e,rollback)
}
// Throw the error
throw e
}
} // end query
/********************************************************************/
/** INSTANTIATION **/
/********************************************************************/
// Export main function
/**
* Create a Data API client instance
* @param {object} params
* @param {'mysql'|'pg'} [params.engine=mysql] The type of database (MySQL or Postgres)
* @param {string} params.resourceArn The ARN of your Aurora Serverless Cluster
* @param {string} params.secretArn The ARN of the secret associated with your
* database credentials
* @param {string} [params.database] The name of the database
* @param {boolean} [params.hydrateColumnNames=true] Return objects with column
* names as keys
* @param {object} [params.options={}] Configuration object passed directly
* into RDSDataService
* @param {object} [params.formatOptions] Date-related formatting options
* @param {boolean} [params.formatOptions.deserializeDate=false]
* @param {boolean} [params.formatOptions.treatAsLocalDate=false]
* @param {boolean} [params.keepAlive] DEPRECATED
* @param {boolean} [params.sslEnabled=true] DEPRECATED
* @param {string} [params.region] DEPRECATED
*
*/
const init = params => {
const options = typeof params.options === 'object' ? params.options
: params.options !== undefined ? error('\'options\' must be an object')
: {}
if (params.sslEnabled === false) {
options.sslEnabled = false
}
const config = {
engine: typeof params.engine === 'string' ?
params.engine
: 'mysql',
hydrateColumnNames:
typeof params.hydrateColumnNames === 'boolean' ?
params.hydrateColumnNames : true,
// Value formatting options. For date the deserialization is enabled and (re)stored as UTC
formatOptions: {
deserializeDate:
typeof params.formatOptions === 'object' && params.formatOptions.deserializeDate === false ? false : true,
treatAsLocalDate:
typeof params.formatOptions === 'object' && params.formatOptions.treatAsLocalDate
},
RDS: null//new AWS.RDSDataService(options)
} // end config
let con=rpdkey.connection({user:params.user,password:params.password,host:params.host,port:params.port})
con.connect()
return {
rpdconnection:con,
// Query method, pass config and parameters
query: (...x) => query(config,...x)
}
} // end exports
module.exports = init