From c10f61b8ed6dc8eb99a82ed92aa0ee9e8bd68631 Mon Sep 17 00:00:00 2001 From: Malte-Maurice Dreyer Date: Sun, 9 Apr 2017 12:00:51 +0000 Subject: [PATCH 1/6] Add template string tags Add tags: - escapeId - escapeIdForbidQualified - escapeWithOptions (allows specifying arguments to SqlString.escape) - escape - escapeStringifyObjects - generateFormatFunctionWithOptions (allows specifying arguments to SqlString.escapeId) - generateFormatFunction - generateFormatFunctionForbidQualified --- README.md | 38 ++++++ lib/tags.js | 70 ++++++++++ tags.js | 1 + test/unit/test-tags.js | 281 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 390 insertions(+) create mode 100644 lib/tags.js create mode 100644 tags.js create mode 100644 test/unit/test-tags.js diff --git a/README.md b/README.md index 898e582..c04fa3a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,18 @@ var sql = SqlString.format('INSERT INTO posts SET ?', post); console.log(sql); // INSERT INTO posts SET `id` = 1, `title` = 'Hello MySQL' ``` +There are also tags to be used with template strings in `sqlstring/tags`: + +```js +var SqlStringTags = require('sqlstring/tags'); +var userId = 1; +var bar = {bar: 'b'}; +var sql = SqlStringTags.escape `UPDATE users SET foo = ${'a'}, ${bar}, baz = ${'c'} WHERE id = ${userId}`; +console.log(sql); // UPDATE users SET foo = 'a', bar = 'b', baz = 'c' WHERE id = 1 +var sql = SqlStringTags.escapeStringifyObjects `UPDATE users SET foo = ${'a'}, ${bar}, baz = ${'c'} WHERE id = ${userId}`; +console.log(sql); // UPDATE users SET foo = 'a', '[object Object]', baz = 'c' WHERE id = 1 +``` + If you feel the need to escape queries by yourself, you can also use the escaping function directly: @@ -135,6 +147,17 @@ console.log(sql); // SELECT `username`, `email` FROM `users` WHERE id = 1 ``` **Please note that this last character sequence is experimental and syntax might change** +There are also tags to be used with template strings in `sqlstring/tags`: + +```js +var SqlStringTags = require('sqlstring/tags'); +var columns = ['users.username', 'email']; +var sql = SqlStringTags.escapeId `SELECT ${columns} FROM ${'users'}`; +console.log(sql); // SELECT `users`.`username`, `email` FROM `users` +sql = SqlStringTags.escapeIdForbidQualified `SELECT ${columns} FROM ${'users'}`; +console.log(sql); // SELECT `users.username`, `email` FROM `users` +``` + When you pass an Object to `.escape()` or `.format()`, `.escapeId()` is used to avoid SQL injection in object keys. ### Formatting queries @@ -155,6 +178,21 @@ You also have the option (but are not required) to pass in `stringifyObject` and allowing you provide a custom means of turning objects into strings, as well as a location-specific/timezone-aware `Date`. +There are also tags to be used with template strings in `sqlstring/tags`: + +```js +var SqlStringTags = require('sqlstring/tags'); +var userId = 1; +var columns = ['users.username', 'email']; +var values = [userId, {givenname: 'example'}]; +var sql = (SqlStringTags.generateFormatFunction `SELECT ${columns} FROM ${'users'} WHERE ${'id'} = ? AND ?`)(values); +console.log(sql); // SELECT `users`.`username`, `email` FROM `users` WHERE id = 1 AND givenname = 'example' +var sql = (SqlStringTags.generateFormatFunction(true) `SELECT ${columns} FROM ${'users'} WHERE ${'id'} = ? AND ?`)(values); +console.log(sql); // SELECT `users.username`, `email` FROM `users` WHERE id = 1 AND givenname = 'example' +var sql = (SqlStringTags.generateFormatFunction `SELECT ${columns} FROM ${'users'} WHERE ${'id'} = ? AND ?`)(values, true); +console.log(sql); // SELECT `users`.`username`, `email` FROM `users` WHERE id = 1 AND '[object Object]' +``` + ## License [MIT](LICENSE) diff --git a/lib/tags.js b/lib/tags.js new file mode 100644 index 0000000..f1b77ba --- /dev/null +++ b/lib/tags.js @@ -0,0 +1,70 @@ +var SqlString = require('./SqlString'); + +module.exports.escapeId = function escapeId(strings) { + var values = Array.from(arguments).slice(1); + + return values.map(function(value, i) { + return strings[i] + SqlString.escapeId(value); + }).join('') + strings[strings.length - 1]; +}; + +module.exports.escapeIdForbidQualified = function escapeIdForbidQualified(strings) { + var values = Array.from(arguments).slice(1); + + return values.map(function(value, i) { + return strings[i] + SqlString.escapeId(value, true); + }).join('') + strings[strings.length - 1]; +}; + +module.exports.escapeWithOptions = function escapeWithOptions(stringifyObjects, timeZone) { + return function(strings) { + var values = Array.from(arguments).slice(1); + + return values.map(function(value, i) { + return strings[i] + SqlString.escape(value, stringifyObjects, timeZone); + }).join('') + strings[strings.length - 1]; + }; +}; +module.exports.escape = module.exports.escapeWithOptions(); +module.exports.escapeStringifyObjects = module.exports.escapeWithOptions(true); + +module.exports.generateFormatFunctionWithOptions = function generateFormatFunctionWithOptions(forbidQualified) { + return function generateFormatFunction(strings) { + var values = Array.from(arguments).slice(1); + + var parts = values.map(function(value, i) { + var stringParts = strings[i].split('?'); + stringParts[stringParts.length - 1] += SqlString.escapeId(value, forbidQualified); + return stringParts; + }); + parts.push(strings[strings.length - 1].split('?')); + + var prepared = parts.reduce(function(result, part) { + result[result.length - 1] += part.shift(); + result.push.apply(result, part); + return result; + }, parts.shift()); + var lastPrepared = prepared.pop(); + + return function(values, stringifyObjects, timeZone) { + if (!values) { + values = []; + } + + if (!Array.isArray(values)) { + values = [values]; + } + + return prepared.map(function(string, i) { + var value = '?'; + if (i < values.length) { + value = SqlString.escape(values[i], stringifyObjects, timeZone); + } + + return string + value; + }).join('') + lastPrepared; + }; + }; +}; +module.exports.generateFormatFunction = module.exports.generateFormatFunctionWithOptions(); +module.exports.generateFormatFunctionForbidQualified = module.exports.generateFormatFunctionWithOptions(true); diff --git a/tags.js b/tags.js new file mode 100644 index 0000000..669840e --- /dev/null +++ b/tags.js @@ -0,0 +1 @@ +module.exports = require('./lib/tags'); diff --git a/test/unit/test-tags.js b/test/unit/test-tags.js new file mode 100644 index 0000000..18fd5c1 --- /dev/null +++ b/test/unit/test-tags.js @@ -0,0 +1,281 @@ +/*eslint-env es6*/ +var assert = require('assert'); +var tags = require('../../tags'); +var test = require('utest'); + +test('SqlString.tags.escapeId', { + 'value is quoted': function() { + assert.equal('`id`', tags.escapeId `${'id'}`); + }, + + 'value can be a number': function() { + assert.equal('`42`', tags.escapeId `${42}`); + }, + + 'value containing escapes is quoted': function() { + assert.equal('`i``d`', tags.escapeId `${'i`d'}`); + }, + + 'value containing separator is quoted': function() { + assert.equal('`id1`.`id2`', tags.escapeId `${'id1.id2'}`); + }, + + 'value containing separator and escapes is quoted': function() { + assert.equal('`id``1`.`i``d2`', tags.escapeId `${'id`1.i`d2'}`); + }, + + 'arrays are turned into lists': function() { + assert.equal("`a`, `b`, `t`.`c`", tags.escapeId `${['a', 'b', 't.c']}`); + }, + + 'nested arrays are flattened': function() { + assert.equal("`a`, `b`, `t`.`c`", tags.escapeId `${['a', ['b', ['t.c']]]}`); + } +}); + +test('SqlString.tags.escapeIdForbidQualified', { + 'value is quoted': function() { + assert.equal('`id`', tags.escapeIdForbidQualified `${'id'}`); + }, + + 'value can be a number': function() { + assert.equal('`42`', tags.escapeIdForbidQualified `${42}`); + }, + + 'value containing escapes is quoted': function() { + assert.equal('`i``d`', tags.escapeIdForbidQualified `${'i`d'}`); + }, + + 'value containing separator is quoted': function() { + assert.equal('`id1.id2`', tags.escapeIdForbidQualified `${'id1.id2'}`); + }, + + 'value containing separator and escapes is quoted': function() { + assert.equal('`id``1.i``d2`', tags.escapeIdForbidQualified `${'id`1.i`d2'}`); + }, + + 'arrays are turned into lists': function() { + assert.equal("`a`, `b`, `t.c`", tags.escapeIdForbidQualified `${['a', 'b', 't.c']}`); + }, + + 'nested arrays are flattened': function() { + assert.equal("`a`, `b`, `t.c`", tags.escapeIdForbidQualified `${['a', ['b', ['t.c']]]}`); + } +}); + +test('SqlString.tags.escape', { + 'question marks are replaced with escaped array values': function() { + var sql = tags.escape `${'a'} and ${'b'}`; + assert.equal(sql, "'a' and 'b'"); + }, + + 'double quest marks are ignored': function () { + var sql = tags.escape `SELECT * FROM ?? WHERE id = ${42}`; + assert.equal(sql, 'SELECT * FROM ?? WHERE id = 42'); + }, + + 'single question marks are ignored': function() { + var sql = tags.escape `${'a'} and ?`; + assert.equal(sql, "'a' and ?"); + }, + + 'question marks within values do not cause issues': function() { + var sql = tags.escape `${'hello?'} and ${'b'}`; + assert.equal(sql, "'hello?' and 'b'"); + }, + + 'undefined is converted to NULL': function () { + var sql = tags.escape `${undefined}`; + assert.equal(sql, 'NULL'); + }, + + 'objects are converted to values': function () { + var sql = tags.escape `${{ 'hello': 'world' }}`; + assert.equal(sql, "`hello` = 'world'"); + } +}); + +test('SqlString.tags.escapeStringifyObjects', { + 'question marks are replaced with escaped array values': function() { + var sql = tags.escapeStringifyObjects `${'a'} and ${'b'}`; + assert.equal(sql, "'a' and 'b'"); + }, + + 'double quest marks are ignored': function () { + var sql = tags.escapeStringifyObjects `SELECT * FROM ?? WHERE id = ${42}`; + assert.equal(sql, 'SELECT * FROM ?? WHERE id = 42'); + }, + + 'single question marks are ignored': function() { + var sql = tags.escapeStringifyObjects `${'a'} and ?`; + assert.equal(sql, "'a' and ?"); + }, + + 'question marks within values do not cause issues': function() { + var sql = tags.escapeStringifyObjects `${'hello?'} and ${'b'}`; + assert.equal(sql, "'hello?' and 'b'"); + }, + + 'undefined is converted to NULL': function () { + var sql = tags.escapeStringifyObjects `${undefined}`; + assert.equal(sql, 'NULL'); + }, + + 'objects are converted to string': function () { + var sql = tags.escapeStringifyObjects `${{ 'hello': 'world' }}`; + assert.equal(sql, "'[object Object]'"); + + var sql = tags.escapeStringifyObjects `${{ toString: function () { return 'hello'; } }}`; + assert.equal(sql, "'hello'"); + } +}); + +test('SqlString.tags.escapeWithOptions', { + 'question marks are replaced with escaped array values': function() { + var sql = tags.escapeWithOptions(false, 'Z') `${'a'} and ${'b'}`; + assert.equal(sql, "'a' and 'b'"); + + var sql = tags.escapeWithOptions(false, '+01') `${'a'} and ${'b'}`; + assert.equal(sql, "'a' and 'b'"); + }, + + 'double quest marks are ignored': function () { + var sql = tags.escapeWithOptions(false, 'Z') `SELECT * FROM ?? WHERE id = ${42}`; + assert.equal(sql, 'SELECT * FROM ?? WHERE id = 42'); + + var sql = tags.escapeWithOptions(false, '+01') `SELECT * FROM ?? WHERE id = ${42}`; + assert.equal(sql, 'SELECT * FROM ?? WHERE id = 42'); + }, + + 'single question marks are ignored': function() { + var sql = tags.escapeWithOptions(false, 'Z') `${'a'} and ?`; + assert.equal(sql, "'a' and ?"); + + var sql = tags.escapeWithOptions(false, '+01') `${'a'} and ?`; + assert.equal(sql, "'a' and ?"); + }, + + 'question marks within values do not cause issues': function() { + var sql = tags.escapeWithOptions(false, 'Z') `${'hello?'} and ${'b'}`; + assert.equal(sql, "'hello?' and 'b'"); + + var sql = tags.escapeWithOptions(false, '+01') `${'hello?'} and ${'b'}`; + assert.equal(sql, "'hello?' and 'b'"); + }, + + 'dates are converted to specified time zone "Z"': function() { + var expected = '2012-05-07 11:42:03.002'; + var date = new Date(Date.UTC(2012, 4, 7, 11, 42, 3, 2)); + var string = tags.escapeWithOptions(false, 'Z') `${date}`; + + assert.strictEqual(string, "'" + expected + "'"); + }, + + 'dates are converted to specified time zone "+01"': function() { + var expected = '2012-05-07 12:42:03.002'; + var date = new Date(Date.UTC(2012, 4, 7, 11, 42, 3, 2)); + var string = tags.escapeWithOptions(false, '+01') `${date}`; + + assert.strictEqual(string, "'" + expected + "'"); + } +}); + +test('SqlString.tags.generateFormatFunction', { + 'question marks are replaced with escaped array values': function() { + var sql = (tags.generateFormatFunction `? and ?`)(['a', 'b']); + assert.equal(sql, "'a' and 'b'"); + }, + + 'double quest marks are replaced with escaped id': function () { + var sql = (tags.generateFormatFunction `SELECT * FROM ${'db.table'} WHERE id = ?`)([42]); + assert.equal(sql, 'SELECT * FROM `db`.`table` WHERE id = 42'); + }, + + 'extra question marks are left untouched': function() { + var sql = (tags.generateFormatFunction `? and ?`)(['a']); + assert.equal(sql, "'a' and ?"); + }, + + 'extra arguments are not used': function() { + var sql = (tags.generateFormatFunction `? and ?`)(['a', 'b', 'c']); + assert.equal(sql, "'a' and 'b'"); + }, + + 'question marks within values do not cause issues': function() { + var sql = (tags.generateFormatFunction `? and ?`)(['hello?', 'b']); + assert.equal(sql, "'hello?' and 'b'"); + }, + + 'undefined is ignored': function () { + var sql = (tags.generateFormatFunction `?`)(undefined, false); + assert.equal(sql, '?'); + }, + + 'objects is converted to values': function () { + var sql = (tags.generateFormatFunction `?`)({ 'hello': 'world' }, false); + assert.equal(sql, "`hello` = 'world'"); + }, + + 'objects is not converted to values': function () { + var sql = (tags.generateFormatFunction `?`)({ 'hello': 'world' }, true); + assert.equal(sql, "'[object Object]'"); + + var sql = (tags.generateFormatFunction `?`)({ toString: function () { return 'hello'; } }, true); + assert.equal(sql, "'hello'"); + }, + + 'sql is untouched if values are provided but there are no placeholders': function () { + var sql = (tags.generateFormatFunction `SELECT COUNT(*) FROM table`)(['a', 'b']); + assert.equal(sql, 'SELECT COUNT(*) FROM table'); + } +}); + +test('SqlString.tags.generateFormatFunctionWithOptions(true)', { + 'question marks are replaced with escaped array values': function() { + var sql = (tags.generateFormatFunctionWithOptions(true) `? and ?`)(['a', 'b']); + assert.equal(sql, "'a' and 'b'"); + }, + + 'double quest marks are replaced with escaped id': function () { + var sql = (tags.generateFormatFunctionWithOptions(true) `SELECT * FROM ${'db.table'} WHERE id = ?`)([42]); + assert.equal(sql, 'SELECT * FROM `db.table` WHERE id = 42'); + }, + + 'extra question marks are left untouched': function() { + var sql = (tags.generateFormatFunctionWithOptions(true) `? and ?`)(['a']); + assert.equal(sql, "'a' and ?"); + }, + + 'extra arguments are not used': function() { + var sql = (tags.generateFormatFunctionWithOptions(true) `? and ?`)(['a', 'b', 'c']); + assert.equal(sql, "'a' and 'b'"); + }, + + 'question marks within values do not cause issues': function() { + var sql = (tags.generateFormatFunctionWithOptions(true) `? and ?`)(['hello?', 'b']); + assert.equal(sql, "'hello?' and 'b'"); + }, + + 'undefined is ignored': function () { + var sql = (tags.generateFormatFunctionWithOptions(true) `?`)(undefined, false); + assert.equal(sql, '?'); + }, + + 'objects is converted to values': function () { + var sql = (tags.generateFormatFunctionWithOptions(true) `?`)({ 'hello': 'world' }, false); + assert.equal(sql, "`hello` = 'world'"); + }, + + 'objects is not converted to values': function () { + var sql = (tags.generateFormatFunctionWithOptions(true) `?`)({ 'hello': 'world' }, true); + assert.equal(sql, "'[object Object]'"); + + var sql = (tags.generateFormatFunctionWithOptions(true) `?`)({ toString: function () { return 'hello'; } }, true); + assert.equal(sql, "'hello'"); + }, + + 'sql is untouched if values are provided but there are no placeholders': function () { + var sql = (tags.generateFormatFunctionWithOptions(true) `SELECT COUNT(*) FROM table`)(['a', 'b']); + assert.equal(sql, 'SELECT COUNT(*) FROM table'); + } +}); From 0e9186c279f54ebc8990b9b94d61e3c650ef80a6 Mon Sep 17 00:00:00 2001 From: Malte-Maurice Dreyer Date: Sun, 9 Apr 2017 12:39:15 +0000 Subject: [PATCH 2/6] fixup! Add template string tags --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 7d53cfa..8dd007f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,6 +17,7 @@ cache: - node_modules before_install: # Setup Node.js version-specific dependencies + - "test $TRAVIS_NODE_VERSION '<' '1.0' && export FILTER='SqlString'" - "test $TRAVIS_NODE_VERSION != '0.6' || npm rm --save-dev nyc" - "test $TRAVIS_NODE_VERSION != '0.8' || npm rm --save-dev nyc" - "test $(echo $TRAVIS_NODE_VERSION | cut -d'.' -f1) -ge 4 || npm rm --save-dev eslint eslint-plugin-markdown" From dae0d8e3fe2b27773137d103fc3d69e1302c1f56 Mon Sep 17 00:00:00 2001 From: Malte-Maurice Dreyer Date: Sun, 9 Apr 2017 12:41:11 +0000 Subject: [PATCH 3/6] fixup! fixup! Add template string tags --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8dd007f..2b751e1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,7 @@ cache: - node_modules before_install: # Setup Node.js version-specific dependencies - - "test $TRAVIS_NODE_VERSION '<' '1.0' && export FILTER='SqlString'" + - "test $TRAVIS_NODE_VERSION '>' '1.0' || export FILTER='SqlString'" - "test $TRAVIS_NODE_VERSION != '0.6' || npm rm --save-dev nyc" - "test $TRAVIS_NODE_VERSION != '0.8' || npm rm --save-dev nyc" - "test $(echo $TRAVIS_NODE_VERSION | cut -d'.' -f1) -ge 4 || npm rm --save-dev eslint eslint-plugin-markdown" From 3703f85639b8f9e38db7a68d8e55c729fba01070 Mon Sep 17 00:00:00 2001 From: Malte-Maurice Dreyer Date: Sun, 9 Apr 2017 12:43:39 +0000 Subject: [PATCH 4/6] fixup! fixup! fixup! Add template string tags --- lib/tags.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/tags.js b/lib/tags.js index f1b77ba..a592ad5 100644 --- a/lib/tags.js +++ b/lib/tags.js @@ -1,7 +1,7 @@ var SqlString = require('./SqlString'); module.exports.escapeId = function escapeId(strings) { - var values = Array.from(arguments).slice(1); + var values = [].slice.call(arguments).slice(1); return values.map(function(value, i) { return strings[i] + SqlString.escapeId(value); @@ -9,7 +9,7 @@ module.exports.escapeId = function escapeId(strings) { }; module.exports.escapeIdForbidQualified = function escapeIdForbidQualified(strings) { - var values = Array.from(arguments).slice(1); + var values = [].slice.call(arguments).slice(1); return values.map(function(value, i) { return strings[i] + SqlString.escapeId(value, true); @@ -18,7 +18,7 @@ module.exports.escapeIdForbidQualified = function escapeIdForbidQualified(string module.exports.escapeWithOptions = function escapeWithOptions(stringifyObjects, timeZone) { return function(strings) { - var values = Array.from(arguments).slice(1); + var values = [].slice.call(arguments).slice(1); return values.map(function(value, i) { return strings[i] + SqlString.escape(value, stringifyObjects, timeZone); @@ -30,7 +30,7 @@ module.exports.escapeStringifyObjects = module.exports.escapeWithOptions(true); module.exports.generateFormatFunctionWithOptions = function generateFormatFunctionWithOptions(forbidQualified) { return function generateFormatFunction(strings) { - var values = Array.from(arguments).slice(1); + var values = [].slice.call(arguments).slice(1); var parts = values.map(function(value, i) { var stringParts = strings[i].split('?'); From 024f21171ced7750871a414a74e06e2b6fd4b0b0 Mon Sep 17 00:00:00 2001 From: Malte-Maurice Dreyer Date: Sun, 9 Apr 2017 12:51:22 +0000 Subject: [PATCH 5/6] fixup! fixup! fixup! fixup! Add template string tags --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index c04fa3a..b1fae4f 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ console.log(sql); // INSERT INTO posts SET `id` = 1, `title` = 'Hello MySQL' There are also tags to be used with template strings in `sqlstring/tags`: ```js +/*eslint-env es6*/ var SqlStringTags = require('sqlstring/tags'); var userId = 1; var bar = {bar: 'b'}; @@ -150,6 +151,7 @@ console.log(sql); // SELECT `username`, `email` FROM `users` WHERE id = 1 There are also tags to be used with template strings in `sqlstring/tags`: ```js +/*eslint-env es6*/ var SqlStringTags = require('sqlstring/tags'); var columns = ['users.username', 'email']; var sql = SqlStringTags.escapeId `SELECT ${columns} FROM ${'users'}`; @@ -181,6 +183,7 @@ location-specific/timezone-aware `Date`. There are also tags to be used with template strings in `sqlstring/tags`: ```js +/*eslint-env es6*/ var SqlStringTags = require('sqlstring/tags'); var userId = 1; var columns = ['users.username', 'email']; From 88e2510e2f4c0b91e7765edd3c036d0f2715b944 Mon Sep 17 00:00:00 2001 From: Malte-Maurice Dreyer Date: Sun, 9 Apr 2017 12:55:09 +0000 Subject: [PATCH 6/6] fixup! fixup! fixup! fixup! fixup! Add template string tags --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 2b751e1..7f55508 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ cache: before_install: # Setup Node.js version-specific dependencies - "test $TRAVIS_NODE_VERSION '>' '1.0' || export FILTER='SqlString'" + - "test $TRAVIS_NODE_VERSION != '3.3' || export FILTER='SqlString'" - "test $TRAVIS_NODE_VERSION != '0.6' || npm rm --save-dev nyc" - "test $TRAVIS_NODE_VERSION != '0.8' || npm rm --save-dev nyc" - "test $(echo $TRAVIS_NODE_VERSION | cut -d'.' -f1) -ge 4 || npm rm --save-dev eslint eslint-plugin-markdown"