@qvalia/knex-aws-data-api
Version:
Knex plugin that uses AWS Data API internally to execute SQL queries. Postgres & Mysql
753 lines (670 loc) • 24.4 kB
JavaScript
/* eslint-disable func-names */
/* eslint-disable no-nested-ternary */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-param-reassign */
/*
* This module provides a simplified interface into the Aurora Serverless
* Data API by abstracting away the notion of field values.
*
* This module is mostly copied over from https://github.com/markusahlstrand/knex-data-api-client
* and added support for AWS SDK V3 and it's new structure changes
*
* More detail regarding the Aurora Serverless Data APIcan be found here:
* https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/data-api.html
*
*/
const {
RDSDataClient,
ExecuteStatementCommand,
BatchExecuteStatementCommand,
BeginTransactionCommand,
CommitTransactionCommand,
RollbackTransactionCommand
} = require('@aws-sdk/client-rds-data');
// 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);
};
// Picked from sqlString module to reduce the dependency chain
function escapeId(val, forbidQualified) {
const ID_GLOBAL_REGEXP = /`/g;
const QUAL_GLOBAL_REGEXP = /\./g;
if (Array.isArray(val)) {
let sql = '';
for (let i = 0; i < val.length; i++) {
sql += (i === 0 ? '' : ', ') + escapeId(val[i], forbidQualified);
}
return sql;
} if (forbidQualified) {
return `\`${String(val).replace(ID_GLOBAL_REGEXP, '``')}\``;
}
return `\`${String(val).replace(ID_GLOBAL_REGEXP, '``').replace(QUAL_GLOBAL_REGEXP, '`.`')}\``;
};
// 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;
// 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] }),
{}
);
// Prepare method params w/ supplied inputs if an object is passed
const prepareParams = ({ secretArn, resourceArn }, args) => ({
secretArn,
resourceArn,
...(typeof args[0] === 'object' ? omit(args[0], ['hydrateColumnNames', 'parameters']) : {}) // merge any inputs
});
// 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), []);
// 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 - String(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);
// Converts object params into name/value format
const splitParams = p =>
Object.keys(p).reduce((arr, x) => arr.concat({ name: x, value: p[x] }), []);
const isDate = val => val instanceof Date;
// 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, 10) === 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);
// Creates a standard Data API parameter using the supplied inputs
const formatType = (name, value, type, typeHint, formatOptions) => Object.assign(
typeHint != null ? { name, typeHint } : { name },
type === null
? { value }
: {
value: {
[type || error(`'${name}' is an invalid type`)]:
type === 'isNull'
? true
: isDate(value)
? formatToTimeStamp(
value,
formatOptions && formatOptions.treatAsLocalDate
)
: value
}
}
); // end formatType
// Converts parameter to the name/value format
const formatParam = (n, v, formatOptions) =>
formatType(n, v, getType(v), getTypeHint(v), formatOptions);
// 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 &&
typeof p.value !== 'undefined') ||
(Object.keys(p).length === 3 &&
p.name &&
typeof 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
) => ({
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]);
} 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, escapeId(p.value));
}
return acc;
}
return acc;
}, []),
escapedSql: sql
});
// 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) => {
(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
p.startsWith('::')
? { type: 'n_id', label: p.substr(2) } // named id
: { type: 'n_ph', label: p.substr(1) } // named placeholder
)
.reduce((acc, x) => Object.assign(acc, {
[x.label]: {
type: x.type
}
}), {}) // end reduce
;
// 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 => x.generatedFields && x.generatedFields.length > 0
? { insertId: x.generatedFields[0].longValue }
: {});
// Merge configuration data with supplied arguments
const mergeConfig = (initialConfig, args) => Object.assign(initialConfig, args);
// 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
const fmap =
recs && recs[0]
? recs[0].map((x, i) =>
({
...(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)
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
}
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
}
// Look for non-null fields
Object.keys(field).forEach(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
// 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 }
: {}
);
/** ***************************************************************** */
/** 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
const result = await (isBatch
? config.RDS.send(new BatchExecuteStatementCommand(params))
: config.RDS.send(new ExecuteStatementCommand(params)));
// Format and return the results
return formatResults(
result,
hydrateColumnNames,
args[0].includeResultMetadata === true,
formatOptions
);
} catch (e) {
if (this && this.rollback) {
const rollback = await config.RDS.send(
new RollbackTransactionCommand(
pick(params, ['resourceArn', 'secretArn', 'transactionId'])
)
);
this.rollback(e, rollback);
}
// Throw the error
throw e;
}
}; // end query
/** ***************************************************************** */
/** TRANSACTION MANAGEMENT * */
/** ***************************************************************** */
// Commit transaction by running queries
const commit = async (config, queries, rollback) => {
const results = []; // keep track of results
// Start a transaction
const { transactionId } = await config.RDS.send(
new BeginTransactionCommand(
pick(config, ['resourceArn', 'secretArn', 'database'])
)
);
// Add transactionId to the config
const txConfig = Object.assign(config, { transactionId });
// Loop through queries
for (let i = 0; i < queries.length; i++) {
// Execute the queries, pass the rollback as context
const result = await query.apply({ rollback }, [
config,
queries[i](results[results.length - 1], results)
]);
// Add the result to the main results accumulator
results.push(result);
}
// Commit our transaction
const { transactionStatus } = await txConfig.RDS.send(
new CommitTransactionCommand(
pick(config, ['resourceArn', 'secretArn', 'transactionId'])
)
);
// Add the transaction status to the results
results.push({ transactionStatus });
// Return the results
return results;
};
// Init a transaction object and return methods
const transaction = (config, _args) => {
const args = typeof _args === 'object' ? [_args] : [{}];
const queries = []; // keep track of queries
let rollback = () => { }; // default rollback event
const txConfig = Object.assign(prepareParams(config, args), {
database: parseDatabase(config, args), // add database
hydrateColumnNames: parseHydrate(config, args), // add hydrate
formatOptions: parseFormatOptions(config, args), // add formatOptions
RDS: config.RDS // reference the RDSDataService instance
});
return {
query(...argslist) {
if (typeof argslist[0] === 'function') {
queries.push(argslist[0]);
} else {
queries.push(() => [...argslist]);
}
return this;
},
rollback(fn) {
if (typeof fn === 'function') {
rollback = fn;
}
return this;
},
async commit() {
return commit(txConfig, queries, rollback);
}
};
};
/** ***************************************************************** */
/** 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 => {
// Set the options for the RDSDataService
const options =
typeof params.options === 'object'
? params.options
: params.options !== undefined
? error("'options' must be an object")
: {};
// Update the AWS http agent with the region
if (typeof params.region === 'string') {
options.region = params.region;
}
// Disable ssl if wanted for local development
if (params.sslEnabled === false) {
options.sslEnabled = false;
}
// Set the configuration for this instance
const config = {
// Require engine
engine: typeof params.engine === 'string' ? params.engine : 'mysql',
// Require secretArn
secretArn:
typeof params.secretArn === 'string'
? params.secretArn
: error("'secretArn' string value required"),
// Require resourceArn
resourceArn:
typeof params.resourceArn === 'string'
? params.resourceArn
: error("'resourceArn' string value required"),
// Load optional database
database:
typeof params.database === 'string'
? params.database
: params.database !== undefined
? error("'database' must be a string")
: undefined,
// Load optional schema DISABLED for now since this isn't used with MySQL
// schema: typeof params.schema === 'string' ? params.schema
// : params.schema !== undefined ? error(`'schema' must be a string`)
// : undefined,
// Set hydrateColumnNames (default to true)
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),
treatAsLocalDate:
typeof params.formatOptions === 'object' &&
params.formatOptions.treatAsLocalDate
},
// Create an instance of RDSDataService
RDS: new RDSDataClient(options)
}; // end config
// Return public methods
return {
// Query method, pass config and parameters
query: (...x) => query(config, ...x),
// Transaction method, pass config and parameters
transaction: x => transaction(config, x),
// Export promisified versions of the RDSDataService methods
batchExecuteStatement: (args) =>
config.RDS.send(
new BatchExecuteStatementCommand(
mergeConfig(pick(config, ['resourceArn', 'secretArn', 'database']), args)
)
),
beginTransaction: (args) =>
config.RDS.send(
new BeginTransactionCommand(
mergeConfig(pick(config, ['resourceArn', 'secretArn', 'database']), args)
)
),
commitTransaction: (args) =>
config.RDS.send(
new CommitTransactionCommand(
mergeConfig(pick(config, ['resourceArn', 'secretArn']), args)
)
),
executeStatement: (args) =>
config.RDS.send(
new ExecuteStatementCommand(
mergeConfig(pick(config, ['resourceArn', 'secretArn', 'database']), args)
)
),
rollbackTransaction: (args) =>
config.RDS.send(
new RollbackTransactionCommand(
mergeConfig(pick(config, ['resourceArn', 'secretArn']), args)
)
)
};
}; // end exports
module.exports = init;