@evidence-dev/mysql
Version:
MySQL driver for Evidence projects
233 lines (214 loc) • 5.92 kB
JavaScript
const {
EvidenceType,
TypeFidelity,
asyncIterableToBatchedAsyncGenerator,
cleanQuery,
exhaustStream
} = require('@evidence-dev/db-commons');
const mysql = require('mysql2');
const mysqlTypes = mysql.Types;
/**
*
* @param {Record<string, unknown>} row
* @returns {Record<string, unknown>}
*/
const standardizeRow = (row) => {
/** @type {Record<string, unknown>} */
const lowerCasedRow = {};
for (const [key, value] of Object.entries(row)) {
lowerCasedRow[key.toLowerCase()] = value;
}
return lowerCasedRow;
};
/**
*
* @param {number} dataTypeId
* @param {undefined} defaultType
* @returns {EvidenceType | undefined}
*/
const nativeTypeToEvidenceType = function (dataTypeId, defaultType = undefined) {
// No native bool https://stackoverflow.com/questions/289727/which-mysql-data-type-to-use-for-storing-boolean-values
switch (dataTypeId) {
case mysqlTypes['DECIMAL']:
case mysqlTypes['TINY']:
case mysqlTypes['SHORT']:
case mysqlTypes['LONG']:
case mysqlTypes['FLOAT']:
case mysqlTypes['DOUBLE']:
case mysqlTypes['NEWDECIMAL']:
case mysqlTypes['INT24']:
case mysqlTypes['LONGLONG']:
return EvidenceType.NUMBER;
case mysqlTypes['TIMESTAMP']:
case mysqlTypes['DATE']:
case mysqlTypes['TIME']:
case mysqlTypes['DATETIME']:
case mysqlTypes['YEAR']:
case mysqlTypes['NEWDATE']:
return EvidenceType.DATE;
case mysqlTypes['VARCHAR']:
case mysqlTypes['VAR_STRING']:
case mysqlTypes['STRING']:
return EvidenceType.STRING;
case mysqlTypes['BIT']:
case mysqlTypes['JSON']:
case mysqlTypes['NULL']:
case mysqlTypes['ENUM']:
case mysqlTypes['SET']:
case mysqlTypes['TINY_BLOB']:
case mysqlTypes['MEDIUM_BLOB']:
case mysqlTypes['LONG_BLOB']:
case mysqlTypes['BLOB']:
case mysqlTypes['GEOMETRY']:
default:
return defaultType;
}
};
/**
*
* @param {mysql.FieldPacket[]} fields
* @returns {import('@evidence-dev/db-commons').ColumnDefinition[] | undefined}
*/
const mapResultsToEvidenceColumnTypes = function (fields) {
return fields?.map((field) => {
/** @type {TypeFidelity} */
let typeFidelity = TypeFidelity.PRECISE;
let evidenceType = nativeTypeToEvidenceType(field.columnType);
if (!evidenceType) {
typeFidelity = TypeFidelity.INFERRED;
evidenceType = EvidenceType.STRING;
}
return {
// We use .toLowerCase() here to match the transformation of
// rows in standardizeResult
// If they do not match the results are rejected.
name: field.name.toLowerCase(),
evidenceType: evidenceType,
typeFidelity: typeFidelity
};
});
};
/** @type {import('@evidence-dev/db-commons').RunQuery<MySQLOptions>} */
const runQuery = async (queryString, database, batchSize = 100000) => {
try {
/** @type {import("mysql2").PoolOptions} */
const credentials = {
user: database.user,
host: database.host,
database: database.database,
password: database.password,
port: database.port,
socketPath: database.socketPath,
decimalNumbers: true
};
const ssl_opt = database.ssl;
if (ssl_opt === 'true') {
credentials.ssl = {};
} else if (ssl_opt === 'Amazon RDS') {
credentials.ssl = 'Amazon RDS';
} else if (!(ssl_opt === 'false' || ssl_opt === '' || ssl_opt === undefined)) {
try {
const obj = JSON.parse(ssl_opt);
credentials.ssl = obj;
} catch (e) {
console.log(e);
}
}
const connection = mysql.createConnection(credentials);
const cleaned_query = cleanQuery(queryString);
const count_query = `WITH root as (${cleaned_query}) SELECT COUNT(*) FROM root`;
const expected_count = await connection
.promise()
.query(count_query)
.catch(() => null);
const expected_row_count = expected_count?.[0][0]['COUNT(*)'];
const query = connection.query(queryString).stream();
const fields = await new Promise((res) => query.on('fields', res));
const result = await asyncIterableToBatchedAsyncGenerator(query, batchSize, {
standardizeRow,
closeConnection: () => connection.destroy()
});
result.columnTypes = mapResultsToEvidenceColumnTypes(fields);
result.expectedRowCount = expected_row_count;
return result;
} catch (err) {
if (err.message) {
throw err.message.replace(/\n|\r/g, ' ');
} else {
throw err.replace(/\n|\r/g, ' ');
}
}
};
module.exports = runQuery;
/**
* @typedef {Object} MySQLOptions
* @property {string} user
* @property {string} host
* @property {string} database
* @property {string} password
* @property {number} port
* @property {string} socketPath
* @property {number} decimalNumbers
* @property {string} ssl
*/
/** @type {import('@evidence-dev/db-commons').GetRunner<MySQLOptions>} */
module.exports.getRunner = async (opts) => {
return async (queryContent, queryPath, batchSize) => {
// Filter out non-sql files
if (!queryPath.endsWith('.sql')) return null;
return runQuery(queryContent, opts, batchSize);
};
};
/** @type {import('@evidence-dev/db-commons').ConnectionTester<PostgresOptions>} */
module.exports.testConnection = async (opts) => {
return await runQuery('SELECT 1;', opts)
.then(exhaustStream)
.then(() => true)
.catch((e) => ({ reason: e.message ?? (e.toString() || 'Invalid Credentials') }));
};
module.exports.options = {
host: {
title: 'Hostname',
type: 'string',
required: true,
secret: false
},
port: {
title: 'Port',
type: 'number',
required: false,
secret: false
},
database: {
title: 'Database',
type: 'string',
required: true,
secret: false
},
user: {
title: 'Username',
type: 'string',
required: true,
secret: false
},
password: {
title: 'Password',
type: 'string',
required: true,
secret: true
},
ssl: {
title: 'SSL',
type: 'string',
required: false,
secret: false
},
socketPath: {
title: 'Socket Path',
type: 'string',
description:
'This is an optional field. When using Google Cloud MySQL this is commonly required.',
required: false,
secret: false
}
};