From b09edcc4086bb399cbb98ccfd618352ac065ea37 Mon Sep 17 00:00:00 2001 From: Mike Samuel Date: Tue, 23 Jan 2018 15:55:09 -0500 Subject: [PATCH 1/6] Add a string template tag handler for securely composing queries. This is a rough draft. It is probably not suitable in its current form. https://nodesecroadmap.fyi/chapter-7/query-langs.html describes this approach as part of a larger discussion about library support for safe coding practices. This enables connection.query`SELECT * FROM T WHERE x = ${x}, y = ${y}, z = ${z}`(callback) and similar idioms. --- index.js | 41 +++- lib/Template.js | 232 ++++++++++++++++++++++ package.json | 3 +- test/common.js | 1 + test/integration/connection/test-query.js | 14 +- test/unit/template/test.js | 159 +++++++++++++++ 6 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 lib/Template.js create mode 100644 test/unit/template/test.js diff --git a/index.js b/index.js index 72624076b..06c43e8a9 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,6 @@ var Classes = Object.create(null); +var calledAsTemplateTagQuick = require('template-tag-common') + .calledAsTemplateTagQuick; /** * Create a new Connection instance. @@ -46,10 +48,18 @@ exports.createPoolCluster = function createPoolCluster(config) { * @return {Query} New query object * @public */ -exports.createQuery = function createQuery(sql, values, callback) { +exports.createQuery = function createQuery(...args) { var Connection = loadClass('Connection'); - - return Connection.createQuery(sql, values, callback); + if (calledAsTemplateTagQuick(args[0], args.length)) { + var Template = loadClass('Template'); + const sqlFragment = Template.sql(...args); + return function (callback) { + return Connection.createQuery(sqlFragment.content, [], callback); + }; + } else { + let [ sql, values, callback ] = args + return Connection.createQuery(sql, values, callback); + } }; /** @@ -106,14 +116,24 @@ exports.raw = function raw(sql) { return SqlString.raw(sql); }; -/** - * The type constants. - * @public - */ -Object.defineProperty(exports, 'Types', { - get: loadClass.bind(null, 'Types') +Object.defineProperties(exports, { + /** + * The type constants. + * @public + */ + 'Types': { + get: loadClass.bind(null, 'Types') + }, + /** + * The SQL template tag. + * @public + */ + 'sql': { + get: loadClass.bind(null, 'Template') + } }); + /** * Load the given class. * @param {string} className Name of class to default @@ -147,6 +167,9 @@ function loadClass(className) { case 'SqlString': Class = require('./lib/protocol/SqlString'); break; + case 'Template': + Class = require('./lib/Template'); + break; case 'Types': Class = require('./lib/protocol/constants/types'); break; diff --git a/lib/Template.js b/lib/Template.js new file mode 100644 index 000000000..df3ccd2b6 --- /dev/null +++ b/lib/Template.js @@ -0,0 +1,232 @@ +const Mysql = require('../index') +const { + memoizedTagFunction, + trimCommonWhitespaceFromLines, + TypedString +} = require('template-tag-common') + +// A simple lexer for SQL. +// SQL has many divergent dialects with subtly different +// conventions for string escaping and comments. +// This just attempts to roughly tokenize MySQL's specific variant. +// See also +// https://www.w3.org/2005/05/22-SPARQL-MySQL/sql_yacc +// https://github.com/twitter/mysql/blob/master/sql/sql_lex.cc +// https://dev.mysql.com/doc/refman/5.7/en/string-literals.html + +// "--" followed by whitespace starts a line comment +// "#" +// "/*" starts an inline comment ended at first "*/" +// \N means null +// Prefixed strings x'...' is a hex string, b'...' is a binary string, .... +// '...', "..." are strings. `...` escapes identifiers. +// doubled delimiters and backslash both escape +// doubled delimiters work in `...` identifiers + +const PREFIX_BEFORE_DELIMITER = new RegExp( + '^(?:' + + ( + // Comment + '--(?=[\\t\\r\\n ])[^\\r\\n]*' + + '|#[^\\r\\n]*' + + '|/[*][\\s\\S]*?[*]/' + ) + + '|' + + ( + // Run of non-comment non-string starts + '(?:[^\'"`\\-/#]|-(?!-)|/(?![*]))' + ) + + ')*') +const DELIMITED_BODIES = { + '\'': /^(?:[^'\\]|\\[\s\S]|'')*/, + '"': /^(?:[^"\\]|\\[\s\S]|"")*/, + '`': /^(?:[^`\\]|\\[\s\S]|``)*/ +} + +/** Template tag that creates a new Error with a message. */ +function msg (strs, ...dyn) { + let message = String(strs[0]) + for (let i = 0; i < dyn.length; ++i) { + message += JSON.stringify(dyn[i]) + strs[i + 1] + } + return message +} + +/** + * Returns a function that can be fed chunks of input and which + * returns a delimiter context. + */ +function makeLexer () { + let errorMessage = null + let delimiter = null + return (text) => { + if (errorMessage) { + // Replay the error message if we've already failed. + throw new Error(errorMessage) + } + text = String(text) + while (text) { + const pattern = delimiter + ? DELIMITED_BODIES[delimiter] + : PREFIX_BEFORE_DELIMITER + const match = pattern.exec(text) + if (!match) { + throw new Error( + errorMessage = msg`Failed to lex starting at ${text}`) + } + let nConsumed = match[0].length + if (text.length > nConsumed) { + const chr = text.charAt(nConsumed) + if (delimiter) { + if (chr === delimiter) { + delimiter = null + ++nConsumed + } else { + throw new Error( + errorMessage = msg`Expected ${chr} at ${text}`) + } + } else if (Object.hasOwnProperty.call(DELIMITED_BODIES, chr)) { + delimiter = chr + ++nConsumed + } else { + throw new Error( + errorMessage = msg`Expected delimiter at ${text}`) + } + } + text = text.substring(nConsumed) + } + return delimiter + } +} + +/** A string wrapper that marks its content as a SQL identifier. */ +class Identifier extends TypedString {} + +/** + * A string wrapper that marks its content as a series of + * well-formed SQL tokens. + */ +class SqlFragment extends TypedString {} + +/** + * Analyzes the static parts of the tag content. + * + * @return An record like { delimiters, chunks } + * where delimiter is a contextual cue and chunk is + * the adjusted raw text. + */ +function computeStatic (strings) { + const { raw } = trimCommonWhitespaceFromLines(strings) + + const delimiters = [] + const chunks = [] + + const lexer = makeLexer() + + let delimiter = null + for (let i = 0, len = raw.length; i < len; ++i) { + let chunk = String(raw[i]) + if (delimiter === '`') { + // Treat raw \` in an identifier literal as an ending delimiter. + chunk = chunk.replace(/^([^\\`]|\\[\s\S])*\\`/, '$1`') + } + const newDelimiter = lexer(chunk) + if (newDelimiter === '`' && !delimiter) { + // Treat literal \` outside a string context as starting an + // identifier literal + chunk = chunk.replace( + /((?:^|[^\\])(?:\\\\)*)\\(`(?:[^`\\]|\\[\s\S])*)$/, '$1$2') + } + + chunks.push(chunk) + delimiters.push(newDelimiter) + delimiter = newDelimiter + } + + if (delimiter) { + throw new Error(`Unclosed quoted string: ${delimiter}`) + } + + return { raw, delimiters, chunks } +} + +function interpolateSqlIntoFragment ( + { raw, delimiters, chunks }, strings, values) { + // A buffer to accumulate output. + let [ result ] = chunks + for (let i = 1, len = raw.length; i < len; ++i) { + const chunk = chunks[i] + // The count of values must be 1 less than the surrounding + // chunks of literal text. + if (i !== 0) { + const delimiter = delimiters[i - 1] + const value = values[i - 1] + if (delimiter) { + result += escapeDelimitedValue(value, delimiter) + } else { + result = appendValue(result, value, chunk) + } + } + + result += chunk + } + + return new SqlFragment(result) +} + +function escapeDelimitedValue (value, delimiter) { + if (delimiter === '`') { + return Mysql.escapeId(String(value)).replace(/^`|`$/g, '') + } + const escaped = Mysql.escape(String(value)) + return escaped.substring(1, escaped.length - 1) +} + +function appendValue (resultBefore, value, chunk) { + let needsSpace = false + let result = resultBefore + const valueArray = Array.isArray(value) ? value : [ value ] + for (let i = 0, nValues = valueArray.length; i < nValues; ++i) { + if (i) { + result += ', ' + } + + const one = valueArray[i] + let valueStr = null + if (one instanceof SqlFragment) { + if (!/(?:^|[\n\r\t ,\x28])$/.test(result)) { + result += ' ' + } + valueStr = one.toString() + needsSpace = i + 1 === nValues + } else if (one instanceof Identifier) { + valueStr = Mysql.escapeId(one.toString()) + } else { + // If we need to handle nested arrays, we would recurse here. + valueStr = Mysql.format('?', one) + } + result += valueStr + } + + if (needsSpace && chunk && !/^[\n\r\t ,\x29]/.test(chunk)) { + result += ' ' + } + + return result +} + +/** + * Template tag function that contextually autoescapes values + * producing a SqlFragment. + */ +const sql = memoizedTagFunction(computeStatic, interpolateSqlIntoFragment) +sql.Identifier = Identifier +sql.Fragment = SqlFragment + +module.exports = sql + +if (global.test) { + // Expose for testing. + // Harmless if this leaks + exports.makeLexer = makeLexer +} diff --git a/package.json b/package.json index b66820932..a57288efc 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "bignumber.js": "4.0.4", "readable-stream": "2.3.3", "safe-buffer": "5.1.1", - "sqlstring": "2.3.0" + "sqlstring": "2.3.0", + "template-tag-common": "1.0.8" }, "devDependencies": { "after": "0.8.2", diff --git a/test/common.js b/test/common.js index db502b60f..b9de44109 100644 --- a/test/common.js +++ b/test/common.js @@ -29,6 +29,7 @@ common.Parser = require(common.lib + '/protocol/Parser'); common.PoolConfig = require(common.lib + '/PoolConfig'); common.PoolConnection = require(common.lib + '/PoolConnection'); common.SqlString = require(common.lib + '/protocol/SqlString'); +common.Template = require(common.lib + '/Template'); common.Types = require(common.lib + '/protocol/constants/types'); var Mysql = require(path.resolve(common.lib, '../index')); diff --git a/test/integration/connection/test-query.js b/test/integration/connection/test-query.js index a11b04dc4..3ea077276 100644 --- a/test/integration/connection/test-query.js +++ b/test/integration/connection/test-query.js @@ -4,17 +4,17 @@ var common = require('../../common'); common.getTestConnection(function (err, connection) { assert.ifError(err); - connection.query('SELECT 1', function (err, rows, fields) { + function callback (err, rows, fields) { assert.ifError(err); assert.deepEqual(rows, [{1: 1}]); assert.equal(fields[0].name, '1'); - }); + } - connection.query({ sql: 'SELECT ?' }, [ 1 ], function (err, rows, fields) { - assert.ifError(err); - assert.deepEqual(rows, [{1: 1}]); - assert.equal(fields[0].name, '1'); - }); + connection.query('SELECT 1', callback); + + connection.query({ sql: 'SELECT ?' }, [ 1 ], callback); + + connection.query`SELECT ${ 1 }`(callback); connection.end(assert.ifError); }); diff --git a/test/unit/template/test.js b/test/unit/template/test.js new file mode 100644 index 000000000..916cece8e --- /dev/null +++ b/test/unit/template/test.js @@ -0,0 +1,159 @@ +var assert = require('assert'); +var common = require('../../common'); +var path = require('path'); +var test = require('utest'); +var Template = common.Template + +function tokens (...chunks) { + const lexer = Template.makeLexer() + const out = [] + for (let i = 0, len = chunks.length; i < len; ++i) { + out.push(lexer(chunks[i]) || '_') + } + return out.join(',') +} + + +test('template lexer', { + 'empty string': function () { + expect(tokens('')).to.equal('_') + }, + 'hash comments': function () { + expect(tokens(' # "foo\n', '')).to.equal('_,_') + }, + 'dash comments': function () { + expect(tokens(' -- \'foo\n', '')).to.equal('_,_') + }, + 'block comments': function () { + expect(tokens(' /* `foo */', '')).to.equal('_,_') + }, + 'dq': function () { + expect(tokens('SELECT "foo"')).to.equal('_') + expect(tokens('SELECT `foo`, "foo"')).to.equal('_') + expect(tokens('SELECT "', '"')).to.equal('",_') + expect(tokens('SELECT "x', '"')).to.equal('",_') + expect(tokens('SELECT "\'', '"')).to.equal('",_') + expect(tokens('SELECT "`', '"')).to.equal('",_') + expect(tokens('SELECT """', '"')).to.equal('",_') + expect(tokens('SELECT "\\"', '"')).to.equal('",_') + }, + 'sq': function () { + expect(tokens('SELECT \'foo\'')).to.equal('_') + expect(tokens('SELECT `foo`, \'foo\'')).to.equal('_') + expect(tokens('SELECT \'', '\'')).to.equal('\',_') + expect(tokens('SELECT \'x', '\'')).to.equal('\',_') + expect(tokens('SELECT \'"', '\'')).to.equal('\',_') + expect(tokens('SELECT \'`', '\'')).to.equal('\',_') + expect(tokens('SELECT \'\'\'', '\'')).to.equal('\',_') + expect(tokens('SELECT \'\\\'', '\'')).to.equal('\',_') + }, + 'bq': function () { + expect(tokens('SELECT `foo`')).to.equal('_') + expect(tokens('SELECT "foo", `foo`')).to.equal('_') + expect(tokens('SELECT `', '`')).to.equal('`,_') + expect(tokens('SELECT `x', '`')).to.equal('`,_') + expect(tokens('SELECT `\'', '`')).to.equal('`,_') + expect(tokens('SELECT `"', '`')).to.equal('`,_') + expect(tokens('SELECT ```', '`')).to.equal('`,_') + expect(tokens('SELECT `\\`', '`')).to.equal('`,_') + } +}) + +function runTagTest (golden, test) { + // Run multiply to test memoization bugs. + for (let i = 3; --i >= 0;) { + let result = test() + if (result instanceof Template.SqlFragment) { + result = result.toString() + } else { + throw new Error(`Expected SqlFragment not ${result}`) + } + expect(result).to.equal(golden) + } +} + +test('template tag', { + 'numbers': function () { + runTagTest( + 'SELECT 2', + () => Template.sql`SELECT ${1 + 1}`) + }, + 'date': function () { + runTagTest( + `SELECT '2000-01-01 00:00:00.000'`, + () => Template.sql`SELECT ${new Date(Date.UTC(2000, 0, 1, 0, 0, 0))}`) + }, + 'string': function () { + runTagTest( + `SELECT 'Hello, World!\\n'`, + () => Template.sql`SELECT ${'Hello, World!\n'}`) + }, + 'identifier': function () { + runTagTest( + 'SELECT `foo`', + () => Template.sql`SELECT ${new Template.Identifier('foo')}`) + }, + 'fragment': function () { + const fragment = new Template.SqlFragment('1 + 1') + runTagTest( + `SELECT 1 + 1`, + () => Template.sql`SELECT ${fragment}`) + }, + 'fragment no token merging': function () { + const fragment = new Template.SqlFragment('1 + 1') + runTagTest( + `SELECT 1 + 1 FROM T`, + () => Template.sql`SELECT${fragment}FROM T`) + }, + 'string in dq string': function () { + runTagTest( + `SELECT "Hello, World!\\n"`, + () => Template.sql`SELECT "Hello, ${'World!'}\n"`) + }, + 'string in sq string': function () { + runTagTest( + `SELECT 'Hello, World!\\n'`, + () => Template.sql`SELECT 'Hello, ${'World!'}\n'`) + }, + 'string after string in string': function () { + // The following tests check obliquely that '?' is not + // interpreted as a prepared statement meta-character + // internally. + runTagTest( + `SELECT 'Hello', "World?"`, + () => Template.sql`SELECT '${'Hello'}', "World?"`) + }, + 'string before string in string': function () { + runTagTest( + `SELECT 'Hello?', 'World?'`, + () => Template.sql`SELECT 'Hello?', '${'World?'}'`) + }, + 'number after string in string': function () { + runTagTest( + `SELECT 'Hello?', 123`, + () => Template.sql`SELECT '${'Hello?'}', ${123}`) + }, + 'number before string in string': function () { + runTagTest( + `SELECT 123, 'World?'`, + () => Template.sql`SELECT ${123}, '${'World?'}'`) + }, + 'string in identifier': function () { + runTagTest( + 'SELECT `foo`', + () => Template.sql`SELECT \`${'foo'}\``) + }, + 'number in identifier': function () { + runTagTest( + 'SELECT `foo_123`', + () => Template.sql`SELECT \`foo_${123}\``) + }, + 'array': function () { + const id = new Template.Identifier('foo') + const frag = new Template.sqlFragment('1 + 1') + const values = [ 123, 'foo', id, frag ] + runTagTest( + "SELECT X FROM T WHERE X IN (123, 'foo', `foo`, 1 + 1)", + () => Template.sql`SELECT X FROM T WHERE X IN (${values})`) + } +}) From 08d6666d30cbe59d33786ade78f556ae5d42ae66 Mon Sep 17 00:00:00 2001 From: Mike Samuel Date: Wed, 24 Jan 2018 11:29:54 -0500 Subject: [PATCH 2/6] Fixed linter errors and some problems in the template tests. --- .eslintrc | 1 + index.js | 5 +- lib/Template.js | 167 ++++++++++++++++++++----------------- test/unit/template/test.js | 124 +++++++++++++-------------- 4 files changed, 157 insertions(+), 140 deletions(-) diff --git a/.eslintrc b/.eslintrc index 7c3d515cd..1df3684d1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,6 @@ { "env": { + "es6": true, "node": true }, "rules": { diff --git a/index.js b/index.js index 06c43e8a9..48ae67dbd 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ var Classes = Object.create(null); var calledAsTemplateTagQuick = require('template-tag-common') - .calledAsTemplateTagQuick; + .calledAsTemplateTagQuick; /** * Create a new Connection instance. @@ -57,7 +57,7 @@ exports.createQuery = function createQuery(...args) { return Connection.createQuery(sqlFragment.content, [], callback); }; } else { - let [ sql, values, callback ] = args + let [ sql, values, callback ] = args; return Connection.createQuery(sql, values, callback); } }; @@ -133,7 +133,6 @@ Object.defineProperties(exports, { } }); - /** * Load the given class. * @param {string} className Name of class to default diff --git a/lib/Template.js b/lib/Template.js index df3ccd2b6..d19406895 100644 --- a/lib/Template.js +++ b/lib/Template.js @@ -1,9 +1,9 @@ -const Mysql = require('../index') +const Mysql = require('../index'); const { memoizedTagFunction, trimCommonWhitespaceFromLines, TypedString -} = require('template-tag-common') +} = require('template-tag-common'); // A simple lexer for SQL. // SQL has many divergent dialects with subtly different @@ -36,67 +36,76 @@ const PREFIX_BEFORE_DELIMITER = new RegExp( // Run of non-comment non-string starts '(?:[^\'"`\\-/#]|-(?!-)|/(?![*]))' ) + - ')*') + ')*'); const DELIMITED_BODIES = { - '\'': /^(?:[^'\\]|\\[\s\S]|'')*/, - '"': /^(?:[^"\\]|\\[\s\S]|"")*/, - '`': /^(?:[^`\\]|\\[\s\S]|``)*/ -} + '\'' : /^(?:[^'\\]|\\[\s\S]|'')*/, + '"' : /^(?:[^"\\]|\\[\s\S]|"")*/, + '`' : /^(?:[^`\\]|\\[\s\S]|``)*/ +}; -/** Template tag that creates a new Error with a message. */ +/** + * Template tag that creates a new Error with a message. + * @param {!Array.} strs a valid TemplateObject. + * @return {string} A message suitable for the Error constructor. + */ function msg (strs, ...dyn) { - let message = String(strs[0]) + let message = String(strs[0]); for (let i = 0; i < dyn.length; ++i) { - message += JSON.stringify(dyn[i]) + strs[i + 1] + message += JSON.stringify(dyn[i]) + strs[i + 1]; } - return message + return message; } /** * Returns a function that can be fed chunks of input and which * returns a delimiter context. + * + * @return {!function (string) : string} + * a stateful function that takes a string of SQL text and + * returns the context after it. Subsequent calls will assume + * that context. */ function makeLexer () { - let errorMessage = null - let delimiter = null + let errorMessage = null; + let delimiter = null; return (text) => { if (errorMessage) { // Replay the error message if we've already failed. - throw new Error(errorMessage) + throw new Error(errorMessage); } - text = String(text) + text = String(text); while (text) { const pattern = delimiter ? DELIMITED_BODIES[delimiter] - : PREFIX_BEFORE_DELIMITER - const match = pattern.exec(text) + : PREFIX_BEFORE_DELIMITER; + const match = pattern.exec(text); if (!match) { throw new Error( - errorMessage = msg`Failed to lex starting at ${text}`) + errorMessage = msg`Failed to lex starting at ${text}`); } - let nConsumed = match[0].length + let nConsumed = match[0].length; if (text.length > nConsumed) { - const chr = text.charAt(nConsumed) + const chr = text.charAt(nConsumed); if (delimiter) { if (chr === delimiter) { - delimiter = null - ++nConsumed + delimiter = null; + ++nConsumed; } else { throw new Error( - errorMessage = msg`Expected ${chr} at ${text}`) + errorMessage = msg`Expected ${chr} at ${text}`); } } else if (Object.hasOwnProperty.call(DELIMITED_BODIES, chr)) { - delimiter = chr - ++nConsumed + delimiter = chr; + ++nConsumed; } else { throw new Error( - errorMessage = msg`Expected delimiter at ${text}`) + errorMessage = msg`Expected delimiter at ${text}`); } } - text = text.substring(nConsumed) + text = text.substring(nConsumed); } - return delimiter - } + return delimiter; + }; } /** A string wrapper that marks its content as a SQL identifier. */ @@ -111,122 +120,128 @@ class SqlFragment extends TypedString {} /** * Analyzes the static parts of the tag content. * - * @return An record like { delimiters, chunks } + * @param {!Array.} strings a valid TemplateObject. + * @return { !{ + * raw: !Array., + * delimiters : !Array., + * chunks: !Array. + * } } + * A record like { raw, delimiters, chunks } * where delimiter is a contextual cue and chunk is * the adjusted raw text. */ function computeStatic (strings) { - const { raw } = trimCommonWhitespaceFromLines(strings) + const { raw } = trimCommonWhitespaceFromLines(strings); - const delimiters = [] - const chunks = [] + const delimiters = []; + const chunks = []; - const lexer = makeLexer() + const lexer = makeLexer(); - let delimiter = null + let delimiter = null; for (let i = 0, len = raw.length; i < len; ++i) { - let chunk = String(raw[i]) + let chunk = String(raw[i]); if (delimiter === '`') { // Treat raw \` in an identifier literal as an ending delimiter. - chunk = chunk.replace(/^([^\\`]|\\[\s\S])*\\`/, '$1`') + chunk = chunk.replace(/^([^\\`]|\\[\s\S])*\\`/, '$1`'); } - const newDelimiter = lexer(chunk) + const newDelimiter = lexer(chunk); if (newDelimiter === '`' && !delimiter) { // Treat literal \` outside a string context as starting an // identifier literal chunk = chunk.replace( - /((?:^|[^\\])(?:\\\\)*)\\(`(?:[^`\\]|\\[\s\S])*)$/, '$1$2') + /((?:^|[^\\])(?:\\\\)*)\\(`(?:[^`\\]|\\[\s\S])*)$/, '$1$2'); } - chunks.push(chunk) - delimiters.push(newDelimiter) - delimiter = newDelimiter + chunks.push(chunk); + delimiters.push(newDelimiter); + delimiter = newDelimiter; } if (delimiter) { - throw new Error(`Unclosed quoted string: ${delimiter}`) + throw new Error(`Unclosed quoted string: ${delimiter}`); } - return { raw, delimiters, chunks } + return { raw, delimiters, chunks }; } function interpolateSqlIntoFragment ( { raw, delimiters, chunks }, strings, values) { // A buffer to accumulate output. - let [ result ] = chunks + let [ result ] = chunks; for (let i = 1, len = raw.length; i < len; ++i) { - const chunk = chunks[i] + const chunk = chunks[i]; // The count of values must be 1 less than the surrounding // chunks of literal text. if (i !== 0) { - const delimiter = delimiters[i - 1] - const value = values[i - 1] + const delimiter = delimiters[i - 1]; + const value = values[i - 1]; if (delimiter) { - result += escapeDelimitedValue(value, delimiter) + result += escapeDelimitedValue(value, delimiter); } else { - result = appendValue(result, value, chunk) + result = appendValue(result, value, chunk); } } - result += chunk + result += chunk; } - return new SqlFragment(result) + return new SqlFragment(result); } function escapeDelimitedValue (value, delimiter) { if (delimiter === '`') { - return Mysql.escapeId(String(value)).replace(/^`|`$/g, '') + return Mysql.escapeId(String(value)).replace(/^`|`$/g, ''); } - const escaped = Mysql.escape(String(value)) - return escaped.substring(1, escaped.length - 1) + const escaped = Mysql.escape(String(value)); + return escaped.substring(1, escaped.length - 1); } function appendValue (resultBefore, value, chunk) { - let needsSpace = false - let result = resultBefore - const valueArray = Array.isArray(value) ? value : [ value ] + let needsSpace = false; + let result = resultBefore; + const valueArray = Array.isArray(value) ? value : [ value ]; for (let i = 0, nValues = valueArray.length; i < nValues; ++i) { if (i) { - result += ', ' + result += ', '; } - const one = valueArray[i] - let valueStr = null + const one = valueArray[i]; + let valueStr = null; if (one instanceof SqlFragment) { if (!/(?:^|[\n\r\t ,\x28])$/.test(result)) { - result += ' ' + result += ' '; } - valueStr = one.toString() - needsSpace = i + 1 === nValues + valueStr = one.toString(); + needsSpace = i + 1 === nValues; } else if (one instanceof Identifier) { - valueStr = Mysql.escapeId(one.toString()) + valueStr = Mysql.escapeId(one.toString()); } else { // If we need to handle nested arrays, we would recurse here. - valueStr = Mysql.format('?', one) + valueStr = Mysql.format('?', one); } - result += valueStr + result += valueStr; } if (needsSpace && chunk && !/^[\n\r\t ,\x29]/.test(chunk)) { - result += ' ' + result += ' '; } - return result + return result; } /** * Template tag function that contextually autoescapes values * producing a SqlFragment. */ -const sql = memoizedTagFunction(computeStatic, interpolateSqlIntoFragment) -sql.Identifier = Identifier -sql.Fragment = SqlFragment - -module.exports = sql +const sql = memoizedTagFunction(computeStatic, interpolateSqlIntoFragment); +sql.Identifier = Identifier; +sql.Fragment = SqlFragment; -if (global.test) { +if (require('process').env.npm_lifecycle_event === 'test') { // Expose for testing. // Harmless if this leaks - exports.makeLexer = makeLexer + sql.makeLexer = makeLexer; } + +module.exports = sql; diff --git a/test/unit/template/test.js b/test/unit/template/test.js index 916cece8e..802c5545f 100644 --- a/test/unit/template/test.js +++ b/test/unit/template/test.js @@ -1,74 +1,76 @@ var assert = require('assert'); var common = require('../../common'); -var path = require('path'); var test = require('utest'); -var Template = common.Template +var Template = common.Template; function tokens (...chunks) { - const lexer = Template.makeLexer() - const out = [] + const lexer = Template.makeLexer(); + const out = []; for (let i = 0, len = chunks.length; i < len; ++i) { - out.push(lexer(chunks[i]) || '_') + out.push(lexer(chunks[i]) || '_'); } - return out.join(',') + return out.join(','); } +for (let k in Template) { + console.log('%s in Template', k); +} test('template lexer', { 'empty string': function () { - expect(tokens('')).to.equal('_') + assert.equal(tokens(''), '_'); }, 'hash comments': function () { - expect(tokens(' # "foo\n', '')).to.equal('_,_') + assert.equal(tokens(' # "foo\n', ''), '_,_'); }, 'dash comments': function () { - expect(tokens(' -- \'foo\n', '')).to.equal('_,_') + assert.equal(tokens(' -- \'foo\n', ''), '_,_'); }, 'block comments': function () { - expect(tokens(' /* `foo */', '')).to.equal('_,_') + assert.equal(tokens(' /* `foo */', ''), '_,_'); }, 'dq': function () { - expect(tokens('SELECT "foo"')).to.equal('_') - expect(tokens('SELECT `foo`, "foo"')).to.equal('_') - expect(tokens('SELECT "', '"')).to.equal('",_') - expect(tokens('SELECT "x', '"')).to.equal('",_') - expect(tokens('SELECT "\'', '"')).to.equal('",_') - expect(tokens('SELECT "`', '"')).to.equal('",_') - expect(tokens('SELECT """', '"')).to.equal('",_') - expect(tokens('SELECT "\\"', '"')).to.equal('",_') + assert.equal(tokens('SELECT "foo"'), '_'); + assert.equal(tokens('SELECT `foo`, "foo"'), '_'); + assert.equal(tokens('SELECT "', '"'), '",_'); + assert.equal(tokens('SELECT "x', '"'), '",_'); + assert.equal(tokens('SELECT "\'', '"'), '",_'); + assert.equal(tokens('SELECT "`', '"'), '",_'); + assert.equal(tokens('SELECT """', '"'), '",_'); + assert.equal(tokens('SELECT "\\"', '"'), '",_'); }, 'sq': function () { - expect(tokens('SELECT \'foo\'')).to.equal('_') - expect(tokens('SELECT `foo`, \'foo\'')).to.equal('_') - expect(tokens('SELECT \'', '\'')).to.equal('\',_') - expect(tokens('SELECT \'x', '\'')).to.equal('\',_') - expect(tokens('SELECT \'"', '\'')).to.equal('\',_') - expect(tokens('SELECT \'`', '\'')).to.equal('\',_') - expect(tokens('SELECT \'\'\'', '\'')).to.equal('\',_') - expect(tokens('SELECT \'\\\'', '\'')).to.equal('\',_') + assert.equal(tokens('SELECT \'foo\''), '_'); + assert.equal(tokens('SELECT `foo`, \'foo\''), '_'); + assert.equal(tokens('SELECT \'', '\''), '\',_'); + assert.equal(tokens('SELECT \'x', '\''), '\',_'); + assert.equal(tokens('SELECT \'"', '\''), '\',_'); + assert.equal(tokens('SELECT \'`', '\''), '\',_'); + assert.equal(tokens('SELECT \'\'\'', '\''), '\',_'); + assert.equal(tokens('SELECT \'\\\'', '\''), '\',_'); }, 'bq': function () { - expect(tokens('SELECT `foo`')).to.equal('_') - expect(tokens('SELECT "foo", `foo`')).to.equal('_') - expect(tokens('SELECT `', '`')).to.equal('`,_') - expect(tokens('SELECT `x', '`')).to.equal('`,_') - expect(tokens('SELECT `\'', '`')).to.equal('`,_') - expect(tokens('SELECT `"', '`')).to.equal('`,_') - expect(tokens('SELECT ```', '`')).to.equal('`,_') - expect(tokens('SELECT `\\`', '`')).to.equal('`,_') + assert.equal(tokens('SELECT `foo`'), '_'); + assert.equal(tokens('SELECT "foo", `foo`'), '_'); + assert.equal(tokens('SELECT `', '`'), '`,_'); + assert.equal(tokens('SELECT `x', '`'), '`,_'); + assert.equal(tokens('SELECT `\'', '`'), '`,_'); + assert.equal(tokens('SELECT `"', '`'), '`,_'); + assert.equal(tokens('SELECT ```', '`'), '`,_'); + assert.equal(tokens('SELECT `\\`', '`'), '`,_'); } -}) +}); function runTagTest (golden, test) { // Run multiply to test memoization bugs. for (let i = 3; --i >= 0;) { - let result = test() - if (result instanceof Template.SqlFragment) { - result = result.toString() + let result = test(); + if (result instanceof Template.Fragment) { + result = result.toString(); } else { - throw new Error(`Expected SqlFragment not ${result}`) + throw new Error(`Expected SqlFragment not ${result}`); } - expect(result).to.equal(golden) + assert.equal(result, golden); } } @@ -76,44 +78,44 @@ test('template tag', { 'numbers': function () { runTagTest( 'SELECT 2', - () => Template.sql`SELECT ${1 + 1}`) + () => Template`SELECT ${1 + 1}`); }, 'date': function () { runTagTest( `SELECT '2000-01-01 00:00:00.000'`, - () => Template.sql`SELECT ${new Date(Date.UTC(2000, 0, 1, 0, 0, 0))}`) + () => Template`SELECT ${new Date(Date.UTC(2000, 0, 1, 0, 0, 0))}`); }, 'string': function () { runTagTest( `SELECT 'Hello, World!\\n'`, - () => Template.sql`SELECT ${'Hello, World!\n'}`) + () => Template`SELECT ${'Hello, World!\n'}`); }, 'identifier': function () { runTagTest( 'SELECT `foo`', - () => Template.sql`SELECT ${new Template.Identifier('foo')}`) + () => Template`SELECT ${new Template.Identifier('foo')}`); }, 'fragment': function () { - const fragment = new Template.SqlFragment('1 + 1') + const fragment = new Template.Fragment('1 + 1'); runTagTest( `SELECT 1 + 1`, - () => Template.sql`SELECT ${fragment}`) + () => Template`SELECT ${fragment}`); }, 'fragment no token merging': function () { - const fragment = new Template.SqlFragment('1 + 1') + const fragment = new Template.Fragment('1 + 1'); runTagTest( `SELECT 1 + 1 FROM T`, - () => Template.sql`SELECT${fragment}FROM T`) + () => Template`SELECT${fragment}FROM T`); }, 'string in dq string': function () { runTagTest( `SELECT "Hello, World!\\n"`, - () => Template.sql`SELECT "Hello, ${'World!'}\n"`) + () => Template`SELECT "Hello, ${'World!'}\n"`); }, 'string in sq string': function () { runTagTest( `SELECT 'Hello, World!\\n'`, - () => Template.sql`SELECT 'Hello, ${'World!'}\n'`) + () => Template`SELECT 'Hello, ${'World!'}\n'`); }, 'string after string in string': function () { // The following tests check obliquely that '?' is not @@ -121,39 +123,39 @@ test('template tag', { // internally. runTagTest( `SELECT 'Hello', "World?"`, - () => Template.sql`SELECT '${'Hello'}', "World?"`) + () => Template`SELECT '${'Hello'}', "World?"`); }, 'string before string in string': function () { runTagTest( `SELECT 'Hello?', 'World?'`, - () => Template.sql`SELECT 'Hello?', '${'World?'}'`) + () => Template`SELECT 'Hello?', '${'World?'}'`); }, 'number after string in string': function () { runTagTest( `SELECT 'Hello?', 123`, - () => Template.sql`SELECT '${'Hello?'}', ${123}`) + () => Template`SELECT '${'Hello?'}', ${123}`); }, 'number before string in string': function () { runTagTest( `SELECT 123, 'World?'`, - () => Template.sql`SELECT ${123}, '${'World?'}'`) + () => Template`SELECT ${123}, '${'World?'}'`); }, 'string in identifier': function () { runTagTest( 'SELECT `foo`', - () => Template.sql`SELECT \`${'foo'}\``) + () => Template`SELECT \`${'foo'}\``); }, 'number in identifier': function () { runTagTest( 'SELECT `foo_123`', - () => Template.sql`SELECT \`foo_${123}\``) + () => Template`SELECT \`foo_${123}\``); }, 'array': function () { - const id = new Template.Identifier('foo') - const frag = new Template.sqlFragment('1 + 1') - const values = [ 123, 'foo', id, frag ] + const id = new Template.Identifier('foo'); + const frag = new Template.Fragment('1 + 1'); + const values = [ 123, 'foo', id, frag ]; runTagTest( "SELECT X FROM T WHERE X IN (123, 'foo', `foo`, 1 + 1)", - () => Template.sql`SELECT X FROM T WHERE X IN (${values})`) + () => Template`SELECT X FROM T WHERE X IN (${values})`); } -}) +}); From 5d0fba02708ab86253881e4b6bef1d717847aa5a Mon Sep 17 00:00:00 2001 From: Mike Samuel Date: Wed, 24 Jan 2018 11:33:52 -0500 Subject: [PATCH 3/6] follow naming convention used elsewhere in test/unit --- test/unit/template/{test.js => test-template.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/unit/template/{test.js => test-template.js} (100%) diff --git a/test/unit/template/test.js b/test/unit/template/test-template.js similarity index 100% rename from test/unit/template/test.js rename to test/unit/template/test-template.js From 67de7f2486c3a4536ec748f447c779e18936a521 Mon Sep 17 00:00:00 2001 From: Mike Samuel Date: Wed, 24 Jan 2018 11:40:36 -0500 Subject: [PATCH 4/6] Add TZ=GMT to testing scripts so that the MySQL client does consistent things when escaping JavaScript Date values --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a57288efc..c99fac997 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,9 @@ }, "scripts": { "lint": "eslint .", - "test": "node test/run.js", - "test-ci": "nyc --reporter=text npm test", - "test-cov": "nyc --reporter=html --reporter=text npm test", + "test": "TZ=GMT node test/run.js", + "test-ci": "TZ=GMT nyc --reporter=text npm test", + "test-cov": "TZ=GMT nyc --reporter=html --reporter=text npm test", "version": "node tool/version-changes.js && git add Changes.md" } } From 24f9ba491c5e6ccc49b3f44122d037ad1abc81d1 Mon Sep 17 00:00:00 2001 From: Mike Samuel Date: Wed, 24 Jan 2018 13:39:35 -0500 Subject: [PATCH 5/6] Change conn.createQuery to work as a template tag --- index.js | 10 +++++----- lib/Connection.js | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 48ae67dbd..86017f010 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ +var ttCommon = require('template-tag-common'); + var Classes = Object.create(null); -var calledAsTemplateTagQuick = require('template-tag-common') - .calledAsTemplateTagQuick; /** * Create a new Connection instance. @@ -50,14 +50,14 @@ exports.createPoolCluster = function createPoolCluster(config) { */ exports.createQuery = function createQuery(...args) { var Connection = loadClass('Connection'); - if (calledAsTemplateTagQuick(args[0], args.length)) { + if (ttCommon.calledAsTemplateTagQuick(args[0], args.length)) { var Template = loadClass('Template'); - const sqlFragment = Template.sql(...args); + const sqlFragment = Template(...args); return function (callback) { return Connection.createQuery(sqlFragment.content, [], callback); }; } else { - let [ sql, values, callback ] = args; + const [ sql, values, callback ] = args; return Connection.createQuery(sql, values, callback); } }; diff --git a/lib/Connection.js b/lib/Connection.js index ea452757e..e11cd0981 100644 --- a/lib/Connection.js +++ b/lib/Connection.js @@ -2,10 +2,12 @@ var Crypto = require('crypto'); var Events = require('events'); var Net = require('net'); var tls = require('tls'); +var ttCommon = require('template-tag-common'); var ConnectionConfig = require('./ConnectionConfig'); var Protocol = require('./protocol/Protocol'); var SqlString = require('./protocol/SqlString'); var Query = require('./protocol/sequences/Query'); +var Template = require('./Template'); var Util = require('util'); module.exports = Connection; @@ -191,8 +193,16 @@ Connection.prototype.rollback = function rollback(options, callback) { return this.query(options, callback); }; -Connection.prototype.query = function query(sql, values, cb) { - var query = Connection.createQuery(sql, values, cb); +Connection.prototype.query = function query(...args) { + if (ttCommon.calledAsTemplateTagQuick(args[0], args.length)) { + const sqlFragment = Template(...args); + return function (callback) { + return this.query(sqlFragment.content, [], callback); + }.bind(this); + } + + const [ sql, values, callback ] = args; + var query = Connection.createQuery(sql, values, callback); query._connection = this; if (!(typeof sql === 'object' && 'typeCast' in sql)) { From cd167c32077c082d8d17384babc336d9f83d1cba Mon Sep 17 00:00:00 2001 From: Mike Samuel Date: Wed, 24 Jan 2018 13:55:52 -0500 Subject: [PATCH 6/6] get rid of debugging cruft --- test/unit/template/test-template.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/unit/template/test-template.js b/test/unit/template/test-template.js index 802c5545f..9ba843a6d 100644 --- a/test/unit/template/test-template.js +++ b/test/unit/template/test-template.js @@ -12,10 +12,6 @@ function tokens (...chunks) { return out.join(','); } -for (let k in Template) { - console.log('%s in Template', k); -} - test('template lexer', { 'empty string': function () { assert.equal(tokens(''), '_');