662 lines
18 KiB
JavaScript
662 lines
18 KiB
JavaScript
// Copyright IBM Corp. 2012,2019. All Rights Reserved.
|
|
// Node module: loopback-connector-mysql
|
|
// This file is licensed under the MIT License.
|
|
// License text available at https://opensource.org/licenses/MIT
|
|
|
|
'use strict';
|
|
const g = require('strong-globalize')();
|
|
|
|
/*!
|
|
* Module dependencies
|
|
*/
|
|
const mysql = require('mysql2');
|
|
|
|
const SqlConnector = require('loopback-connector').SqlConnector;
|
|
const ParameterizedSQL = SqlConnector.ParameterizedSQL;
|
|
const EnumFactory = require('./enumFactory').EnumFactory;
|
|
|
|
const debug = require('debug')('loopback:connector:mysql');
|
|
const setHttpCode = require('./set-http-code');
|
|
|
|
/**
|
|
* @module loopback-connector-mysql
|
|
*
|
|
* Initialize the MySQL connector against the given data source
|
|
*
|
|
* @param {DataSource} dataSource The loopback-datasource-juggler dataSource
|
|
* @param {Function} [callback] The callback function
|
|
*/
|
|
exports.initialize = function initializeDataSource(dataSource, callback) {
|
|
dataSource.driver = mysql; // Provide access to the native driver
|
|
dataSource.connector = new MySQL(dataSource.settings);
|
|
dataSource.connector.dataSource = dataSource;
|
|
|
|
defineMySQLTypes(dataSource);
|
|
|
|
dataSource.EnumFactory = EnumFactory; // factory for Enums. Note that currently Enums can not be registered.
|
|
|
|
if (callback) {
|
|
if (dataSource.settings.lazyConnect) {
|
|
process.nextTick(function() {
|
|
callback();
|
|
});
|
|
} else {
|
|
dataSource.connector.connect(callback);
|
|
}
|
|
}
|
|
};
|
|
|
|
exports.MySQL = MySQL;
|
|
|
|
function defineMySQLTypes(dataSource) {
|
|
const modelBuilder = dataSource.modelBuilder;
|
|
const defineType = modelBuilder.defineValueType ?
|
|
// loopback-datasource-juggler 2.x
|
|
modelBuilder.defineValueType.bind(modelBuilder) :
|
|
// loopback-datasource-juggler 1.x
|
|
modelBuilder.constructor.registerType.bind(modelBuilder.constructor);
|
|
|
|
// The Point type is inherited from jugglingdb mysql adapter.
|
|
// LoopBack uses GeoPoint instead.
|
|
// The Point type can be removed at some point in the future.
|
|
defineType(function Point() {
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @constructor
|
|
* Constructor for MySQL connector
|
|
* @param {Object} client The node-mysql client object
|
|
*/
|
|
function MySQL(settings) {
|
|
SqlConnector.call(this, 'mysql', settings);
|
|
}
|
|
|
|
require('util').inherits(MySQL, SqlConnector);
|
|
|
|
MySQL.prototype.multiInsertSupported = true;
|
|
|
|
MySQL.prototype.connect = function(callback) {
|
|
const self = this;
|
|
const options = generateOptions(this.settings);
|
|
const s = self.settings || {};
|
|
|
|
if (this.client) {
|
|
if (callback) {
|
|
process.nextTick(function() {
|
|
callback(null, self.client);
|
|
});
|
|
}
|
|
} else {
|
|
this.client = mysql.createPool(options);
|
|
this.client.getConnection(function(err, connection) {
|
|
const conn = connection;
|
|
if (!err) {
|
|
if (self.debug) {
|
|
debug('MySQL connection is established: ' + self.settings || {});
|
|
}
|
|
connection.release();
|
|
} else {
|
|
if (self.debug || !callback) {
|
|
console.error('MySQL connection is failed: ' + self.settings || {}, err);
|
|
}
|
|
}
|
|
callback && callback(err, conn);
|
|
});
|
|
}
|
|
};
|
|
|
|
function generateOptions(settings) {
|
|
const s = settings || {};
|
|
const generatorSpecificOptions = [
|
|
'name',
|
|
'connector',
|
|
'sharedData',
|
|
'forwardErrorToEnvironment',
|
|
'skipLocalCache',
|
|
'_',
|
|
'c',
|
|
'y',
|
|
'initialGenerator',
|
|
'resolved',
|
|
'namespace',
|
|
'skip-cache',
|
|
'skip-install',
|
|
'force-install',
|
|
'ask-answered',
|
|
'config',
|
|
'yes',
|
|
'url',
|
|
'engine',
|
|
'collation',
|
|
];
|
|
if (s.collation) {
|
|
// Charset should be first 'chunk' of collation.
|
|
s.charset = s.collation.substr(0, s.collation.indexOf('_'));
|
|
} else {
|
|
s.collation = 'utf8_general_ci';
|
|
s.charset = 'utf8';
|
|
}
|
|
|
|
s.supportBigNumbers = (s.supportBigNumbers || false);
|
|
s.timezone = (s.timezone || 'local');
|
|
|
|
if (isNaN(s.connectionLimit)) {
|
|
s.connectionLimit = 10;
|
|
}
|
|
|
|
let options;
|
|
if (s.url) {
|
|
// use url to override other settings if url provided
|
|
options = s.url;
|
|
} else {
|
|
options = {
|
|
host: s.host || s.hostname || 'localhost',
|
|
port: s.port || 3306,
|
|
user: s.username || s.user,
|
|
password: s.password,
|
|
timezone: s.timezone,
|
|
socketPath: s.socketPath,
|
|
charset: s.collation.toUpperCase(), // Correct by docs despite seeming odd.
|
|
supportBigNumbers: s.supportBigNumbers,
|
|
connectionLimit: s.connectionLimit,
|
|
};
|
|
|
|
// Don't configure the DB if the pool can be used for multiple DBs
|
|
if (!s.createDatabase) {
|
|
options.database = s.database;
|
|
}
|
|
|
|
// Take other options for mysql driver
|
|
// See https://github.com/loopbackio/loopback-connector-mysql/issues/46
|
|
for (const p in s) {
|
|
if (
|
|
(p === 'database' && s.createDatabase) ||
|
|
generatorSpecificOptions.includes(p)
|
|
) {
|
|
continue;
|
|
}
|
|
if (options[p] === undefined) {
|
|
options[p] = s[p];
|
|
}
|
|
}
|
|
// Legacy UTC Date Processing fallback - SHOULD BE TRANSITIONAL
|
|
if (s.legacyUtcDateProcessing === undefined) {
|
|
s.legacyUtcDateProcessing = true;
|
|
}
|
|
if (s.legacyUtcDateProcessing) {
|
|
options.timezone = 'Z';
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
/**
|
|
* Execute the sql statement
|
|
*
|
|
* @param {String} sql The SQL statement
|
|
* @param {Function} [callback] The callback after the SQL statement is executed
|
|
*/
|
|
MySQL.prototype.executeSQL = function(sql, params, options, callback) {
|
|
const self = this;
|
|
const client = this.client;
|
|
const debugEnabled = debug.enabled;
|
|
const db = this.settings.database;
|
|
if (typeof callback !== 'function') {
|
|
throw new Error(g.f('{{callback}} should be a function'));
|
|
}
|
|
if (debugEnabled) {
|
|
debug('SQL: %s, params: %j', sql, params);
|
|
}
|
|
|
|
const transaction = options.transaction;
|
|
|
|
function handleResponse(connection, err, result) {
|
|
if (!transaction) {
|
|
connection.release();
|
|
}
|
|
if (err) {
|
|
err = setHttpCode(err);
|
|
}
|
|
callback && callback(err, result);
|
|
}
|
|
|
|
function runQuery(connection, release) {
|
|
connection.query(sql, params, function(err, data) {
|
|
if (debugEnabled) {
|
|
if (err) {
|
|
debug('Error: %j', err);
|
|
}
|
|
debug('Data: ', data);
|
|
}
|
|
handleResponse(connection, err, data);
|
|
});
|
|
}
|
|
|
|
function executeWithConnection(err, connection) {
|
|
if (err) {
|
|
return callback && callback(err);
|
|
}
|
|
if (self.settings.createDatabase) {
|
|
// Call USE db ...
|
|
connection.query('USE ??', [db], function(err) {
|
|
if (err) {
|
|
if (err && err.message.match(/(^|: )unknown database/i)) {
|
|
const charset = self.settings.charset;
|
|
const collation = self.settings.collation;
|
|
const q = 'CREATE DATABASE ?? CHARACTER SET ?? COLLATE ??';
|
|
connection.query(q, [db, charset, collation], function(err) {
|
|
if (!err) {
|
|
connection.query('USE ??', [db], function(err) {
|
|
runQuery(connection);
|
|
});
|
|
} else {
|
|
handleResponse(connection, err);
|
|
}
|
|
});
|
|
return;
|
|
} else {
|
|
handleResponse(connection, err);
|
|
return;
|
|
}
|
|
}
|
|
runQuery(connection);
|
|
});
|
|
} else {
|
|
// Bypass USE db
|
|
runQuery(connection);
|
|
}
|
|
}
|
|
|
|
if (transaction && transaction.connection &&
|
|
transaction.connector === this) {
|
|
if (debugEnabled) {
|
|
debug('Execute SQL within a transaction');
|
|
}
|
|
executeWithConnection(null, transaction.connection);
|
|
} else {
|
|
client.getConnection(executeWithConnection);
|
|
}
|
|
};
|
|
|
|
MySQL.prototype._modifyOrCreate = function(model, data, options, fields, cb) {
|
|
const sql = new ParameterizedSQL('INSERT INTO ' + this.tableEscaped(model));
|
|
const columnValues = fields.columnValues;
|
|
const fieldNames = fields.names;
|
|
if (fieldNames.length) {
|
|
sql.merge('(' + fieldNames.join(',') + ')', '');
|
|
const values = ParameterizedSQL.join(columnValues, ',');
|
|
values.sql = 'VALUES(' + values.sql + ')';
|
|
sql.merge(values);
|
|
} else {
|
|
sql.merge(this.buildInsertDefaultValues(model, data, options));
|
|
}
|
|
|
|
sql.merge('ON DUPLICATE KEY UPDATE');
|
|
const setValues = [];
|
|
for (let i = 0, n = fields.names.length; i < n; i++) {
|
|
if (!fields.properties[i].id) {
|
|
setValues.push(new ParameterizedSQL(fields.names[i] + '=' +
|
|
columnValues[i].sql, columnValues[i].params));
|
|
}
|
|
}
|
|
|
|
sql.merge(ParameterizedSQL.join(setValues, ','));
|
|
|
|
this.execute(sql.sql, sql.params, options, function(err, info) {
|
|
if (!err && info && info.insertId) {
|
|
data.id = info.insertId;
|
|
}
|
|
const meta = {};
|
|
// When using the INSERT ... ON DUPLICATE KEY UPDATE statement,
|
|
// the returned value is as follows:
|
|
// 1 for each successful INSERT.
|
|
// 2 for each successful UPDATE.
|
|
// 1 also for UPDATE with same values, so we cannot accurately
|
|
// report if we have a new instance.
|
|
meta.isNewInstance = undefined;
|
|
cb(err, data, meta);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Replace if the model instance exists with the same id or create a new instance
|
|
*
|
|
* @param {String} model The model name
|
|
* @param {Object} data The model instance data
|
|
* @param {Object} options The options
|
|
* @param {Function} [cb] The callback function
|
|
*/
|
|
MySQL.prototype.replaceOrCreate = function(model, data, options, cb) {
|
|
const fields = this.buildReplaceFields(model, data);
|
|
this._modifyOrCreate(model, data, options, fields, cb);
|
|
};
|
|
|
|
/**
|
|
* Update if the model instance exists with the same id or create a new instance
|
|
*
|
|
* @param {String} model The model name
|
|
* @param {Object} data The model instance data
|
|
* @param {Object} options The options
|
|
* @param {Function} [cb] The callback function
|
|
*/
|
|
MySQL.prototype.save =
|
|
MySQL.prototype.updateOrCreate = function(model, data, options, cb) {
|
|
const fields = this.buildFields(model, data);
|
|
this._modifyOrCreate(model, data, options, fields, cb);
|
|
};
|
|
|
|
MySQL.prototype.getInsertedId = function(model, info) {
|
|
const insertedId = info && typeof info.insertId === 'number' ?
|
|
info.insertId : undefined;
|
|
return insertedId;
|
|
};
|
|
|
|
MySQL.prototype.getInsertedIds = function(model, info) {
|
|
let insertedIds = [];
|
|
const idProp = this.getDataSource(model).idProperty(model);
|
|
if (info && info.affectedRows > 0) {
|
|
insertedIds = new Array(info.affectedRows);
|
|
for (let i = 0; i < info.affectedRows; i++) {
|
|
insertedIds[i] = idProp.generated && typeof idProp.type() === 'number' &&
|
|
typeof info.insertId === 'number' && info.insertId > 0 ?
|
|
info.insertId + i : undefined;
|
|
}
|
|
}
|
|
return insertedIds;
|
|
};
|
|
|
|
/*!
|
|
* Convert property name/value to an escaped DB column value
|
|
* @param {Object} prop Property descriptor
|
|
* @param {*} val Property value
|
|
* @returns {*} The escaped value of DB column
|
|
*/
|
|
MySQL.prototype.toColumnValue = function(prop, val) {
|
|
if (val === undefined && this.isNullable(prop)) {
|
|
return null;
|
|
}
|
|
if (val === null) {
|
|
if (this.isNullable(prop)) {
|
|
return val;
|
|
} else if (prop.type === Date) {
|
|
// MySQL has disallowed comparison of date types with strings.
|
|
// https://bugs.mysql.com/bug.php?id=95466
|
|
// https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-16.html
|
|
return new Date();
|
|
} else {
|
|
try {
|
|
const castNull = prop.type(val);
|
|
if (prop.type === Object) {
|
|
return JSON.stringify(castNull);
|
|
}
|
|
return castNull;
|
|
} catch (err) {
|
|
// if we can't coerce null to a certain type,
|
|
// we just return it
|
|
return 'null';
|
|
}
|
|
}
|
|
}
|
|
if (!prop) {
|
|
return val;
|
|
}
|
|
if (prop.type === String) {
|
|
return String(val);
|
|
}
|
|
if (prop.type === Number) {
|
|
if (isNaN(val)) {
|
|
// FIXME: [rfeng] Should fail fast?
|
|
return val;
|
|
}
|
|
return val;
|
|
}
|
|
if (prop.type === Date) {
|
|
if (!val.toUTCString) {
|
|
val = new Date(val);
|
|
}
|
|
return val;
|
|
}
|
|
if (prop.type.name === 'DateString') {
|
|
return val.when;
|
|
}
|
|
if (prop.type === Boolean) {
|
|
return !!val;
|
|
}
|
|
if (prop.type.name === 'GeoPoint') {
|
|
return new ParameterizedSQL({
|
|
sql: 'Point(?,?)',
|
|
params: [val.lng, val.lat],
|
|
});
|
|
}
|
|
if (prop.type === Buffer) {
|
|
return val;
|
|
}
|
|
if (prop.type === Object) {
|
|
return this._serializeObject(val);
|
|
}
|
|
if (typeof prop.type === 'function') {
|
|
return this._serializeObject(val);
|
|
}
|
|
return this._serializeObject(val);
|
|
};
|
|
|
|
MySQL.prototype._serializeObject = function(obj) {
|
|
let val;
|
|
if (obj && typeof obj.toJSON === 'function') {
|
|
obj = obj.toJSON();
|
|
}
|
|
if (typeof obj !== 'string') {
|
|
val = JSON.stringify(obj);
|
|
} else {
|
|
val = obj;
|
|
}
|
|
return val;
|
|
};
|
|
|
|
/*!
|
|
* Convert the data from database column to model property
|
|
* @param {object} Model property descriptor
|
|
* @param {*) val Column value
|
|
* @returns {*} Model property value
|
|
*/
|
|
MySQL.prototype.fromColumnValue = function(prop, val) {
|
|
if (val == null) {
|
|
return val;
|
|
}
|
|
if (prop) {
|
|
switch (prop.type.name) {
|
|
case 'Number':
|
|
val = Number(val);
|
|
break;
|
|
case 'String':
|
|
val = String(val);
|
|
break;
|
|
case 'Date':
|
|
case 'DateString':
|
|
// MySQL allows, unless NO_ZERO_DATE is set, dummy date/time entries
|
|
// new Date() will return Invalid Date for those, so we need to handle
|
|
// those separate.
|
|
if (!val || /^0{4}(-00){2}( (00:){2}0{2}(\.0{1,6}){0,1}){0,1}$/.test(val)) {
|
|
val = null;
|
|
}
|
|
break;
|
|
case 'Boolean':
|
|
// BIT(1) case: <Buffer 01> for true and <Buffer 00> for false
|
|
// CHAR(1) case: '1' for true and '0' for false
|
|
// TINYINT(1) case: 1 for true and 0 for false
|
|
val = Buffer.isBuffer(val) && val.length === 1 ? Boolean(val[0]) : Boolean(parseInt(val));
|
|
break;
|
|
case 'GeoPoint':
|
|
case 'Point':
|
|
val = {
|
|
lng: val.x,
|
|
lat: val.y,
|
|
};
|
|
break;
|
|
case 'ObjectID':
|
|
val = new prop.type(val);
|
|
break;
|
|
case 'Buffer':
|
|
val = prop.type(val);
|
|
break;
|
|
case 'List':
|
|
case 'Array':
|
|
case 'Object':
|
|
case 'JSON':
|
|
if (typeof val === 'string') {
|
|
val = JSON.parse(val);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
return val;
|
|
};
|
|
|
|
/**
|
|
* Escape an identifier such as the column name
|
|
* @param {string} name A database identifier
|
|
* @returns {string} The escaped database identifier
|
|
*/
|
|
MySQL.prototype.escapeName = function(name) {
|
|
return this.client.escapeId(name);
|
|
};
|
|
|
|
/**
|
|
* Build the LIMIT clause
|
|
* @param {string} model Model name
|
|
* @param {number} limit The limit
|
|
* @param {number} offset The offset
|
|
* @returns {string} The LIMIT clause
|
|
*/
|
|
MySQL.prototype._buildLimit = function(model, limit, offset) {
|
|
if (isNaN(limit)) {
|
|
limit = 0;
|
|
}
|
|
if (isNaN(offset)) {
|
|
offset = 0;
|
|
}
|
|
if (!limit && !offset) {
|
|
return '';
|
|
}
|
|
return 'LIMIT ' + (offset ? (offset + ',' + limit) : limit);
|
|
};
|
|
|
|
MySQL.prototype.applyPagination = function(model, stmt, filter) {
|
|
const limitClause = this._buildLimit(model, filter.limit,
|
|
filter.offset || filter.skip);
|
|
return stmt.merge(limitClause);
|
|
};
|
|
|
|
/**
|
|
* Get the place holder in SQL for identifiers, such as ??
|
|
* @param {String} key Optional key, such as 1 or id
|
|
* @returns {String} The place holder
|
|
*/
|
|
MySQL.prototype.getPlaceholderForIdentifier = function(key) {
|
|
return '??';
|
|
};
|
|
|
|
/**
|
|
* Get the place holder in SQL for values, such as :1 or ?
|
|
* @param {String} key Optional key, such as 1 or id
|
|
* @returns {String} The place holder
|
|
*/
|
|
MySQL.prototype.getPlaceholderForValue = function(key) {
|
|
return '?';
|
|
};
|
|
|
|
MySQL.prototype.getCountForAffectedRows = function(model, info) {
|
|
const affectedRows = info && typeof info.affectedRows === 'number' ?
|
|
info.affectedRows : undefined;
|
|
return affectedRows;
|
|
};
|
|
|
|
/**
|
|
* Disconnect from MySQL
|
|
*/
|
|
MySQL.prototype.disconnect = function(cb) {
|
|
if (this.debug) {
|
|
debug('disconnect');
|
|
}
|
|
if (this.client) {
|
|
this.client.end((err) => {
|
|
this.client = null;
|
|
cb(err);
|
|
});
|
|
} else {
|
|
process.nextTick(cb);
|
|
}
|
|
};
|
|
|
|
MySQL.prototype.ping = function(cb) {
|
|
this.execute('SELECT 1 AS result', cb);
|
|
};
|
|
|
|
MySQL.prototype.buildExpression = function(columnName, operator, operatorValue,
|
|
propertyDefinition) {
|
|
let clause;
|
|
switch (operator) {
|
|
case 'regexp':
|
|
// https://dev.mysql.com/doc/refman/8.0/en/regexp.html#function_regexp-like
|
|
// REGEXP_LIKE(expr, pat[, match_type]) - match_type parameter now support c,i and m flags of RegExp
|
|
let matchType = '';
|
|
if (operatorValue.ignoreCase === false) {
|
|
matchType += 'c';
|
|
} else if (operatorValue.ignoreCase === true) {
|
|
matchType += 'i';
|
|
}
|
|
|
|
if (operatorValue.multiline) {
|
|
matchType += 'm';
|
|
}
|
|
|
|
if (operatorValue.global) {
|
|
g.warn('{{MySQL}} {{regex}} syntax does not respect the {{`g`}} flag');
|
|
}
|
|
|
|
if (!!matchType) {
|
|
clause = `REGEXP_LIKE(${columnName}, ?, '${matchType}')`;
|
|
} else {
|
|
clause = `REGEXP_LIKE(${columnName}, ?)`;
|
|
}
|
|
|
|
return new ParameterizedSQL(clause,
|
|
[operatorValue.source]);
|
|
case 'matchnl':
|
|
case 'matchqe':
|
|
case 'matchnlqe':
|
|
case 'matchbool':
|
|
case 'match':
|
|
let mode;
|
|
switch (operator) {
|
|
case 'matchbool':
|
|
mode = ' IN BOOLEAN MODE';
|
|
break;
|
|
case 'matchnl':
|
|
mode = ' IN NATURAL LANGUAGE MODE';
|
|
break;
|
|
case 'matchqe':
|
|
mode = ' WITH QUERY EXPANSION';
|
|
break;
|
|
case 'matchnlqe':
|
|
mode = ' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION';
|
|
break;
|
|
default:
|
|
mode = '';
|
|
}
|
|
clause = ` MATCH (${columnName}) AGAINST (?${mode})`;
|
|
|
|
return new ParameterizedSQL(clause, [operatorValue]);
|
|
}
|
|
|
|
// invoke the base implementation of `buildExpression`
|
|
return this.invokeSuper('buildExpression', columnName, operator,
|
|
operatorValue, propertyDefinition);
|
|
};
|
|
|
|
require('./migration')(MySQL, mysql);
|
|
require('./discovery')(MySQL, mysql);
|
|
require('./transaction')(MySQL, mysql);
|