不要怂,就是干,撸起袖子干!

Commit 1b8d2ade by Mick Hansen

Merge pull request #4688 from User4martin/master

implement bind-parameter for custom queries on postgres
2 parents 44eee347 6e7cee2c
......@@ -47,3 +47,31 @@ sequelize.query('SELECT * FROM projects WHERE status = :status ',
console.log(projects)
})
```
# Bind Parameter
Bind parameters are like replacemnets. Except replacements are escaped and inserted into the query by sequelize before the query is sent to the database, while bind parameters are sent to the database outside the sql query text. A query can have either bind parameters or replacments.
Only Sqlite and Postgresql support bind parameters. Other dialects will insert them into the sql query in the same way it is done for replacements. Bind parameters are referred to by either $1, $2, ... (numeric) or $key (alpha-numeric). This is independent of the dialect.
* If an array is passed, `$1` will be bound to the 1st element in the array (`bind[0]`)
* If an object is passed, `$key` will be bound to `object['key']`. Each key must have a none numeric char. `$1` is not a valid key, even if `object['1']` exists.
* In either case `$$` can be used to escape a literal `$` sign.
All bound values must be present in the array/object or an exception will be thrown. This applies even to cases in which the database may ignore the bound parameter.
The database may add further restrictions to this. Bind parameters can not be sql keywords, nor table or column names. They are also ignored in quoted text/data. In Postgresql it may also be needed to typecast them, if the type can not be inferred from the context `$1::varchar`.
```js
sequelize.query('SELECT *, "text with literal $$1 and literal $$status" as t FROM projects WHERE status = $1',
{ bind: ['active'], type: sequelize.QueryTypes.SELECT }
).then(function(projects) {
console.log(projects)
})
sequelize.query('SELECT *, "text with literal $$1 and literal $$status" as t FROM projects WHERE status = $status',
{ bind: { status: 'active' }, type: sequelize.QueryTypes.SELECT }
).then(function(projects) {
console.log(projects)
})
```
'use strict';
var Utils = require('../../utils')
, SqlString = require('../../sql-string')
, Dot = require('dottie')
, QueryTypes = require('../../query-types');
......@@ -381,6 +382,85 @@ var groupJoinData = function(rows, includeOptions, options) {
};
/**
* rewrite query with parameters
*
* Examples:
*
* query.formatBindParameters('select $1 as foo', ['fooval']);
*
* query.formatBindParameters('select $foo as foo', { foo: 'fooval' });
*
* Options
* skipUnescape: bool, skip unescaping $$
* skipValueReplace: bool, do not replace (but do unescape $$). Check correct syntax and if all values are available
*/
AbstractQuery.formatBindParameters = function(sql, values, dialect, replacementFunc, options) {
if (!values) {
return [sql, []];
}
options = options || {};
if (typeof replacementFunc !== 'function') {
options = replacementFunc || {};
replacementFunc = undefined;
}
if (!replacementFunc) {
if (options.skipValueReplace) {
replacementFunc = function(match, key, values, timeZone, dialect, options) {
if (values[key] !== undefined) {
return match;
}
return undefined;
};
} else {
replacementFunc = function(match, key, values, timeZone, dialect, options) {
if (values[key] !== undefined) {
return SqlString.escape(values[key], false, timeZone, dialect);
}
return undefined;
};
}
} else {
if (options.skipValueReplace) {
var origReplacementFunc = replacementFunc;
replacementFunc = function(match, key, values, timeZone, dialect, options) {
if (origReplacementFunc(match, key, values, timeZone, dialect, options) !== undefined) {
return match;
}
return undefined;
};
}
}
var timeZone = null;
var list = Array.isArray(values);
sql = sql.replace(/\$(\$|\w+)/g, function(match, key) {
if ('$' === key) {
return options.skipUnescape ? match : key;
}
var replVal;
if (list) {
if (key.match(/^[1-9]\d*$/)) {
key = key - 1;
replVal = replacementFunc(match, key, values, timeZone, dialect, options);
}
} else {
if (!key.match(/^\d*$/)) {
replVal = replacementFunc(match, key, values, timeZone, dialect, options);
}
}
if (replVal === undefined) {
throw new Error('Named bind parameter "' + match + '" has no value in the given object.');
}
return replVal;
});
return [sql, []];
};
/**
* Execute the passed sql query.
*
* Examples:
......
......@@ -24,7 +24,8 @@ Query.prototype.getInsertIdField = function() {
return 'id';
};
Query.prototype.run = function(sql) {
Query.formatBindParameters = AbstractQuery.formatBindParameters;
Query.prototype.run = function(sql, parameters) {
var self = this;
this.sql = sql;
......
......@@ -21,7 +21,8 @@ var Query = function(connection, sequelize, options) {
};
Utils.inherit(Query, AbstractQuery);
Query.prototype.run = function(sql) {
Query.formatBindParameters = AbstractQuery.formatBindParameters;
Query.prototype.run = function(sql, parameters) {
var self = this;
this.sql = sql;
......
......@@ -87,7 +87,35 @@ Utils.inherit(Query, AbstractQuery);
Query.prototype.parseDialectSpecificFields = parseDialectSpecificFields;
Query.prototype.run = function(sql) {
/**
* rewrite query with parameters
*/
Query.formatBindParameters = function(sql, values, dialect) {
var bindParam = [];
if (Array.isArray(values)) {
bindParam = values;
sql = AbstractQuery.formatBindParameters(sql, values, dialect, { skipValueReplace: true })[0];
} else {
var i = 0;
var seen = {};
var replacementFunc = function(match, key, values, timeZone, dialect, options) {
if (seen[key] !== undefined) {
return seen[key];
}
if (values[key] !== undefined) {
i = i + 1;
bindParam.push(values[key]);
seen[key] = '$'+i;
return '$'+i;
}
return undefined;
};
sql = AbstractQuery.formatBindParameters(sql, values, dialect, replacementFunc)[0];
}
return [sql, bindParam];
};
Query.prototype.run = function(sql, parameters) {
this.sql = sql;
if(!Utils._.isEmpty(this.options.searchPath)){
......@@ -96,7 +124,7 @@ Query.prototype.run = function(sql) {
var self = this
, receivedError = false
, query = this.client.query(this.sql)
, query = ((parameters && parameters.length) ? this.client.query(this.sql, parameters) : this.client.query(this.sql))
, rows = [];
this.sequelize.log('Executing (' + (this.client.uuid || 'default') + '): ' + this.sql, this.options);
......
......@@ -24,11 +24,40 @@ Query.prototype.getInsertIdField = function() {
return 'lastID';
};
Query.prototype.run = function(sql) {
/**
* rewrite query with parameters
*/
Query.formatBindParameters = function(sql, values, dialect) {
var bindParam = [];
if (Array.isArray(values)) {
bindParam = {};
values.forEach(function(v, i) {
bindParam['$'+(i+1)] = v;
});
sql = AbstractQuery.formatBindParameters(sql, values, dialect, { skipValueReplace: true })[0];
} else {
bindParam = {};
if (typeof values === 'object') {
Object.keys(values).forEach(function(k) {
bindParam['$'+k] = values[k];
});
}
sql = AbstractQuery.formatBindParameters(sql, values, dialect, { skipValueReplace: true })[0];
}
return [sql, bindParam];
};
Query.prototype.run = function(sql, parameters) {
var self = this
, promise;
this.sql = sql;
var method = self.getDatabaseMethod();
if (method === 'exec') {
// exec does not support bind parameter
sql = AbstractQuery.formatBindParameters(sql, self.options.bind, self.options.dialect, { skipUnescape: true })[0];
this.sql = sql;
}
this.sequelize.log('Executing (' + (this.database.uuid || 'default') + '): ' + this.sql, this.options);
......@@ -41,7 +70,7 @@ Query.prototype.run = function(sql) {
return resolve();
} else {
resolve(new Utils.Promise(function(resolve, reject) {
self.database[self.getDatabaseMethod()](self.sql, function(err, results) {
var afterExecute = function(err, results) {
if (err) {
err.sql = self.sql;
reject(self.formatError(err));
......@@ -139,7 +168,15 @@ Query.prototype.run = function(sql) {
resolve(result);
}
});
};
if (method === 'exec') {
// exec does not support bind parameter
self.database[method](self.sql, afterExecute);
} else {
if (!parameters) parameters = [];
self.database[method](self.sql, parameters, afterExecute);
}
}));
}
};
......
......@@ -666,6 +666,7 @@ Sequelize.prototype.import = function(path) {
* @param {Boolean} [options.nest=false] If true, transforms objects with `.` separated property names into nested objects using [dottie.js](https://github.com/mickhansen/dottie.js). For example { 'user.username': 'john' } becomes { user: { username: 'john' }}. When `nest` is true, the query type is assumed to be `'SELECT'`, unless otherwise specified
* @param {Boolean} [options.plain=false] Sets the query type to `SELECT` and return a single row
* @param {Object|Array} [options.replacements] Either an object of named parameter replacements in the format `:param` or an array of unnamed replacements to replace `?` in your SQL.
* @param {Object|Array} [options.bind] Either an object of named bind parameter in the format `$param` or an array of unnamed bind parameter to replace `$1, $2, ...` in your SQL.
* @param {Boolean} [options.useMaster=false] Force the query to use the write pool, regardless of the query type.
* @param {Function} [options.logging=false] A function that gets executed while running the query to log the sql.
* @param {Instance} [options.instance] A sequelize instance used to build the return instance
......@@ -707,6 +708,14 @@ Sequelize.prototype.query = function(sql, options) {
options.replacements = sql.values;
}
if (sql.bind !== undefined) {
if (options.bind !== undefined) {
throw new Error('Both `sql.bind` and `options.bind` cannot be set at the same time');
}
options.bind = sql.bind;
}
if (sql.query !== undefined) {
sql = sql.query;
}
......@@ -718,6 +727,9 @@ Sequelize.prototype.query = function(sql, options) {
options.raw = true;
}
if (options.replacements && options.bind) {
throw new Error('Both `replacements` and `bind` cannot be set at the same time');
}
if (options.replacements) {
if (Array.isArray(options.replacements)) {
sql = Utils.format([sql].concat(options.replacements), this.options.dialect);
......@@ -726,6 +738,12 @@ Sequelize.prototype.query = function(sql, options) {
sql = Utils.formatNamedParameters(sql, options.replacements, this.options.dialect);
}
}
var bindParameters;
if (options.bind) {
var bindSql = self.dialect.Query.formatBindParameters(sql, options.bind, this.options.dialect);
sql = bindSql[0];
bindParameters = bindSql[1];
}
options = Utils._.extend(Utils._.clone(this.options.query), options);
options = Utils._.defaults(options, {
......@@ -768,7 +786,7 @@ Sequelize.prototype.query = function(sql, options) {
options.transaction ? options.transaction.connection : self.connectionManager.getConnection(options)
).then(function (connection) {
var query = new self.dialect.Query(connection, self, options);
return query.run(sql).finally(function() {
return query.run(sql, bindParameters).finally(function() {
if (options.transaction) return;
return self.connectionManager.releaseConnection(connection);
});
......
......@@ -349,9 +349,58 @@ describe(Support.getTestDialectTeaser('Sequelize'), function() {
}).to.throw(Error, 'Both `sql.values` and `options.replacements` cannot be set at the same time');
});
it('throw an exception if `sql.bind` and `options.bind` are both passed', function() {
var self = this;
expect(function() {
return self.sequelize.query({ query: 'select $1 + ? as foo, $2 + ? as bar', bind: [1, 2] }, { raw: true, bind: [1, 2] });
}).to.throw(Error, 'Both `sql.bind` and `options.bind` cannot be set at the same time');
});
it('throw an exception if `options.replacements` and `options.bind` are both passed', function() {
var self = this;
expect(function() {
return self.sequelize.query('select $1 + ? as foo, $2 + ? as bar', { raw: true, bind: [1, 2], replacements: [1, 2] });
}).to.throw(Error, 'Both `replacements` and `bind` cannot be set at the same time');
});
it('throw an exception if `sql.bind` and `sql.values` are both passed', function() {
var self = this;
expect(function() {
return self.sequelize.query({ query: 'select $1 + ? as foo, $2 + ? as bar', bind: [1, 2], values: [1, 2] }, { raw: true });
}).to.throw(Error, 'Both `replacements` and `bind` cannot be set at the same time');
});
it('throw an exception if `sql.bind` and `options.replacements`` are both passed', function() {
var self = this;
expect(function() {
return self.sequelize.query({ query: 'select $1 + ? as foo, $2 + ? as bar', bind: [1, 2] }, { raw: true, replacements: [1, 2] });
}).to.throw(Error, 'Both `replacements` and `bind` cannot be set at the same time');
});
it('throw an exception if `options.bind` and `sql.replacements` are both passed', function() {
var self = this;
expect(function() {
return self.sequelize.query({ query: 'select $1 + ? as foo, $1 _ ? as bar', values: [1, 2] }, { raw: true, bind: [1, 2] });
}).to.throw(Error, 'Both `replacements` and `bind` cannot be set at the same time');
});
it('uses properties `query` and `values` if query is tagged', function() {
return this.sequelize.query({ query: 'select ? as foo, ? as bar', values: [1, 2] }, { type: this.sequelize.QueryTypes.SELECT }).then(function(result) {
var logSql;
return this.sequelize.query({ query: 'select ? as foo, ? as bar', values: [1, 2] }, { type: this.sequelize.QueryTypes.SELECT, logging: function(s) { logSql = s; } }).then(function(result) {
expect(result).to.deep.equal([{ foo: 1, bar: 2 }]);
expect(logSql.indexOf('?')).to.equal(-1);
});
});
it('uses properties `query` and `bind` if query is tagged', function() {
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
return this.sequelize.query({ query: 'select $1'+typeCast+' as foo, $2'+typeCast+' as bar', bind: [1, 2] }, { type: this.sequelize.QueryTypes.SELECT, logging: function(s) { logSql = s; } }).then(function(result) {
expect(result).to.deep.equal([{ foo: 1, bar: 2 }]);
if ((dialect === 'postgres') || (dialect === 'sqlite')) {
expect(logSql.indexOf('$1')).to.be.above(-1);
expect(logSql.indexOf('$2')).to.be.above(-1);
}
});
});
......@@ -432,6 +481,145 @@ describe(Support.getTestDialectTeaser('Sequelize'), function() {
}).to.throw(Error, /Named parameter ":\w+" has no value in the given object\./g);
});
it('binds token with the passed array', function() {
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
return this.sequelize.query('select $1'+typeCast+' as foo, $2'+typeCast+' as bar', { type: this.sequelize.QueryTypes.SELECT, bind: [1, 2], logging: function(s) { logSql = s; } }).then(function(result) {
expect(result).to.deep.equal([{ foo: 1, bar: 2 }]);
if ((dialect === 'postgres') || (dialect === 'sqlite')) {
expect(logSql.indexOf('$1')).to.be.above(-1);
}
});
});
it('binds named parameters with the passed object', function() {
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
return this.sequelize.query('select $one'+typeCast+' as foo, $two'+typeCast+' as bar', { raw: true, bind: { one: 1, two: 2 }, logging: function(s) { logSql = s; } }).then(function(result) {
expect(result[0]).to.deep.equal([{ foo: 1, bar: 2 }]);
if ((dialect === 'postgres')) {
expect(logSql.indexOf('$1')).to.be.above(-1);
}
if ((dialect === 'sqlite')) {
expect(logSql.indexOf('$one')).to.be.above(-1);
}
});
});
it('binds named parameters with the passed object using the same key twice', function() {
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
return this.sequelize.query('select $one'+typeCast+' as foo, $two'+typeCast+' as bar, $one'+typeCast+' as baz', { raw: true, bind: { one: 1, two: 2 }, logging: function(s) { logSql = s; } }).then(function(result) {
expect(result[0]).to.deep.equal([{ foo: 1, bar: 2, baz: 1 }]);
if ((dialect === 'postgres')) {
expect(logSql.indexOf('$1')).to.be.above(-1);
expect(logSql.indexOf('$2')).to.be.above(-1);
expect(logSql.indexOf('$3')).to.equal(-1);
}
});
});
it('binds named parameters with the passed object having a null property', function() {
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
return this.sequelize.query('select $one'+typeCast+' as foo, $two'+typeCast+' as bar', { raw: true, bind: { one: 1, two: null }, logging: function(s) { logSql = s; } }).then(function(result) {
expect(result[0]).to.deep.equal([{ foo: 1, bar: null }]);
});
});
it('binds named parameters array handles escaped $$', function() {
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
return this.sequelize.query('select $1'+typeCast+' as foo, \'$$ / $$1\' as bar', { raw: true, bind: [1 ], logging: function(s) { logSql = s; } }).then(function(result) {
expect(result[0]).to.deep.equal([{ foo: 1, bar: '$ / $1' }]);
if ((dialect === 'postgres') || (dialect === 'sqlite')) {
expect(logSql.indexOf('$1')).to.be.above(-1);
}
});
});
it('binds named parameters object handles escaped $$', function() {
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
return this.sequelize.query('select $one'+typeCast+' as foo, \'$$ / $$one\' as bar', { raw: true, bind: { one: 1 }, logging: function(s) { logSql = s; } }).then(function(result) {
expect(result[0]).to.deep.equal([{ foo: 1, bar: '$ / $one' }]);
});
});
it('throw an exception when binds passed with object and numeric $1 is also present', function() {
var self = this;
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
expect(function() {
self.sequelize.query('select $one'+typeCast+' as foo, $two'+typeCast+' as bar, \'$1\' as baz', { raw: true, bind: { one: 1, two: 2 }, logging: function(s) { logSql = s; } });
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception when binds passed as array and $alpha is also present', function() {
var self = this;
var typeCast = (dialect === 'postgres') ? '::int' : '';
var logSql;
expect(function() {
self.sequelize.query('select $1'+typeCast+' as foo, $2'+typeCast+' as bar, \'$foo\' as baz', { raw: true, bind: [1, 2], logging: function(s) { logSql = s; } });
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception when bind key is $0 with the passed array', function() {
var self = this;
expect(function() {
self.sequelize.query('select $1 as foo, $0 as bar, $3 as baz', { raw: true, bind: [1, 2] });
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception when bind key is $01 with the passed array', function() {
var self = this;
expect(function() {
self.sequelize.query('select $1 as foo, $01 as bar, $3 as baz', { raw: true, bind: [1, 2] });
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception when bind key is missing in the passed array', function() {
var self = this;
expect(function() {
self.sequelize.query('select $1 as foo, $2 as bar, $3 as baz', { raw: true, bind: [1, 2] });
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception when bind key is missing in the passed object', function() {
var self = this;
expect(function() {
self.sequelize.query('select $one as foo, $two as bar, $three as baz', { raw: true, bind: { one: 1, two: 2 }});
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception with the passed number for bind', function() {
var self = this;
expect(function() {
self.sequelize.query('select $one as foo, $two as bar', { raw: true, bind: 2 });
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception with the passed empty object for bind', function() {
var self = this;
expect(function() {
self.sequelize.query('select $one as foo, $two as bar', { raw: true, bind: {}});
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception with the passed string for bind', function() {
var self = this;
expect(function() {
self.sequelize.query('select $one as foo, $two as bar', { raw: true, bind: 'foobar'});
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('throw an exception with the passed date for bind', function() {
var self = this;
expect(function() {
self.sequelize.query('select $one as foo, $two as bar', { raw: true, bind: new Date()});
}).to.throw(Error, /Named bind parameter "\$\w+" has no value in the given object\./g);
});
it('handles AS in conjunction with functions just fine', function() {
var datetime = (dialect === 'sqlite' ? 'date(\'now\')' : 'NOW()');
if (dialect === 'mssql') {
......@@ -454,6 +642,27 @@ describe(Support.getTestDialectTeaser('Sequelize'), function() {
.to.eventually.deep.equal([{ 'sum': '5050' }]);
});
}
if (Support.getTestDialect() === 'sqlite') {
it('binds array parameters for upsert are replaced. $$ unescapes only once', function() {
var logSql;
return this.sequelize.query('select $1 as foo, $2 as bar, \'$$$$\' as baz', { type: this.sequelize.QueryTypes.UPSERT, bind: [1, 2], logging: function(s) { logSql = s; } }).then(function(result) {
// sqlite.exec does not return a result
expect(logSql.indexOf('$one')).to.equal(-1);
expect(logSql.indexOf('\'$$\'')).to.be.above(-1);
});
});
it('binds named parameters for upsert are replaced. $$ unescapes only once', function() {
var logSql;
return this.sequelize.query('select $one as foo, $two as bar, \'$$$$\' as baz', { type: this.sequelize.QueryTypes.UPSERT, bind: { one: 1, two: 2 }, logging: function(s) { logSql = s; } }).then(function(result) {
// sqlite.exec does not return a result
expect(logSql.indexOf('$one')).to.equal(-1);
expect(logSql.indexOf('\'$$\'')).to.be.above(-1);
});
});
}
});
describe('set', function() {
......
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!