From fc306fa95c280a26fdf771007a908f3b42e5d5ae Mon Sep 17 00:00:00 2001 From: Mike Samuel Date: Fri, 26 Jan 2018 16:07:46 -0500 Subject: [PATCH] Contextual string tags to prevent SQL injection 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 is one step in a larger effort to enable connection.query`SELECT * FROM T WHERE x = ${x}, y = ${y}, z = ${z}`(callback) and similar idioms. This was broken out of https://github.com/mysqljs/mysql/pull/1926 --- README.md | 90 +++- docs/sql-railroad.svg | 855 ++++++++++++++++++++++++++++++++++++ index.js | 1 + lib/SqlString.js | 30 +- lib/Template.js | 32 ++ lib/es6/.eslintrc | 3 + lib/es6/Lexer.js | 109 +++++ lib/es6/README.md | 4 + lib/es6/Template.js | 123 ++++++ package.json | 3 + test/README.md | 12 + test/unit/es6/.eslintrc | 3 + test/unit/es6/Lexer.js | 79 ++++ test/unit/es6/Template.js | 212 +++++++++ test/unit/es6/canary.js | 7 + test/unit/test-Lexer.js | 12 + test/unit/test-SqlString.js | 68 +++ test/unit/test-Template.js | 43 ++ 18 files changed, 1683 insertions(+), 3 deletions(-) create mode 100644 docs/sql-railroad.svg create mode 100644 lib/Template.js create mode 100644 lib/es6/.eslintrc create mode 100644 lib/es6/Lexer.js create mode 100644 lib/es6/README.md create mode 100644 lib/es6/Template.js create mode 100644 test/README.md create mode 100644 test/unit/es6/.eslintrc create mode 100644 test/unit/es6/Lexer.js create mode 100644 test/unit/es6/Template.js create mode 100644 test/unit/es6/canary.js create mode 100644 test/unit/test-Lexer.js create mode 100644 test/unit/test-Template.js diff --git a/README.md b/README.md index fafe6f4..908fe2e 100644 --- a/README.md +++ b/README.md @@ -102,12 +102,16 @@ console.log(sql); // UPDATE posts SET modified = CURRENT_TIMESTAMP() WHERE id = ``` To generate objects with a `toSqlString` method, the `SqlString.raw()` method can -be used. This creates an object that will be left un-touched when using in a `?` +be used. This creates an object that will be left un-touched when used in a `?` placeholder, useful for using functions as dynamic values: **Caution** The string provided to `SqlString.raw()` will skip all escaping functions when used, so be careful when passing in unvalidated input. +Similarly, `SqlString.identifier(id, forbidQualified)` creates an object with a +`toSqlString` method that returns `SqlString.escapeId(id, forbidQualified)`. +Its result is not re-escaped when used in a `?` or `??` placeholder. + ```js var CURRENT_TIMESTAMP = SqlString.raw('CURRENT_TIMESTAMP()'); var sql = SqlString.format('UPDATE posts SET modified = ? WHERE id = ?', [CURRENT_TIMESTAMP, 42]); @@ -150,6 +154,15 @@ var sql = 'SELECT * FROM posts ORDER BY ' + SqlString.escapeId(sorter, true); console.log(sql); // SELECT * FROM posts ORDER BY `date.2` ``` +If `escapeId` receives an object with a `toSqlString` method, then `escapeId` uses +that method's result after coercing it to a string. + +```js +var sorter = SqlString.identifier('date'); // ({ toSqlString: () => '`date`' }) +var sql = 'SELECT * FROM posts ORDER BY ' + sqlString.escapeId(sorter); +console.log(sql); // SELECT * FROM posts ORDER BY `date` +``` + Alternatively, you can use `??` characters as placeholders for identifiers you would like to have escaped like this: @@ -161,7 +174,8 @@ console.log(sql); // SELECT `username`, `email` FROM `users` WHERE id = 1 ``` **Please note that this last character sequence is experimental and syntax might change** -When you pass an Object to `.escape()` or `.format()`, `.escapeId()` is used to avoid SQL injection in object keys. +When you pass an Object to `.escape()` or `.format()`, `.escapeId()` +is used to avoid SQL injection in object keys. ### Formatting queries @@ -191,6 +205,78 @@ var sql = SqlString.format('UPDATE ?? SET ? WHERE `id` = ?', ['users', data, console.log(sql); // UPDATE `users` SET `email` = 'foobar@example.com', `modified` = NOW() WHERE `id` = 1 ``` +### ES6 Template Tag Support + +`SqlString.sql` works as a template tag in Node versions that support ES6 features +(node runtime versions 6 and later). + +```es6 +var column = 'users'; +var userId = 1; +var data = { email: 'foobar@example.com', modified: SqlString.raw('NOW()') }; +var fromFormat = SqlString.format('UPDATE ?? SET ? WHERE `id` = ?', [column, data, userId]); +var fromTag = SqlString.sql`UPDATE \`${column}\` SET ${data} WHERE \`id\` = ${userId}`; + +console.log(fromFormat); +console.log(fromTag.toSqlString()); +// Both emit: +// UPDATE `users` SET `email` = 'foobar@example.com', `modified` = NOW() WHERE `id` = 1 +``` + + +There are some differences between `SqlString.format` and `SqlString.raw`: + +* The `SqlString.sql` tag returns a raw chunk SQL as if by `SqlString.raw`, + whereas `SqlString.format` returns a string. + This allows chaining: + ```es6 + let data = { a: 1 }; + let whereClause = SqlString.sql`WHERE ${data}`; + SqlString.sql`SELECT * FROM TABLE ${whereClause}`.toSqlString(); + // SELECT * FROM TABLE WHERE `a` = 1 + ``` +* An interpolation in a quoted string will not insert excess quotes: + ```es6 + SqlString.sql`SELECT '${ 'foo' }' `.toSqlString() === `SELECT 'foo' `; + SqlString.sql`SELECT ${ 'foo' } `.toSqlString() === `SELECT 'foo' `; + SqlString.format("SELECT '?' ", ['foo']) === `SELECT ''foo'' `; + ``` + This means that you can interpolate a string into an ID thus: + ```es6 + SqlString.sql`SELECT * FROM \`${ 'table' }\``.toSqlString() === 'SELECT * FROM `table`' + SqlString.format('SELECT * FROM ??', ['table']) === 'SELECT * FROM `table`' + ``` +* Backticks end a template tag, so you need to escape backticks. + ```es6 + SqlString.sql`SELECT \`${ 'id' }\` FROM \`TABLE\``.toSqlString() + === 'SELECT `id` FROM `TABLE`' + ``` +* Other escape sequences are raw. + ```es6 + SqlString.sql`SELECT "\n"`.toSqlString() === 'SELECT "\\n"' + SqlString.format('SELECT "\n"', []) === 'SELECT "\n"' + SqlString.format(String.raw`SELECT "\n"`, []) === 'SELECT "\\n"' + ``` +* `SqlString.format` takes options at the end, but `SqlString.sql` + takes an options object in a separate call. + ```es6 + let timeZone = 'GMT'; + let date = new Date(Date.UTC(2000, 0, 1)); + SqlString.sql({ timeZone })`SELECT ${date}`.toSqlString() === + 'SELECT \'2000-01-01 00:00:00.000\''; + SqlString.format('SELECT ?', [date], false, timezone) === + 'SELECT \'2000-01-01 00:00:00.000\''; + ``` + The options object can contain any of + `{ stringifyObjects, timeZone, forbidQualified }` which have the + same meaning as when used with other `SqlString` APIs. + +`SqlString.sql` handles `${...}` inside quoted strings as if the tag +matched the following grammar: + +[![Railroad Diagram](docs/sql-railroad.svg)](docs/sql-railroad.svg) + + ## License [MIT](LICENSE) diff --git a/docs/sql-railroad.svg b/docs/sql-railroad.svg new file mode 100644 index 0000000..a31bc9c --- /dev/null +++ b/docs/sql-railroad.svg @@ -0,0 +1,855 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +block comment + + + + + + + +/* + + + + + + + + + + + + + + + + + + + + + + + +/ + + + + + + + + + + + + + + + + + + + + + + + +* + + + + + + + + + + + + + + + +[^*/] + + + + + + + + + + + + + + + + + + +*/ + + + + + + + + + + +line comment + + + + + + + + + + +# + + + + + + + + + + +-- + + + + + + + +space + + + + + + + + + + + + + + + + + + + + + +[^\r\n] + + + + + + + + + + + + + + + + + + + +hole + + + + + + + +${ + + + + + + + +escape + + + + + + + +} + + + + + + + + + + +normal char + + + + + + + +[^'"`\\] + + + + + + + + + + +escape + + + + + + + +\ + + + + + + + +char + + + + + + + + + + +quoted + + + + + + + +" + + + + + + + + + + + + + + + + + + + + + + + +"" + + + + + + + +[^"\\] + + + + + + + + + + +\ + + + + + + + +char + + + + + + + + + + + +${ + + + + + + + +escapeChars + + + + + + + +} + + + + + + + + + + + + + + + + + + +" + + + + + + + + + + +quoted + + + + + + + +' + + + + + + + + + + + + + + + + + + + + + + + +'' + + + + + + + +[^'\\] + + + + + + + + + + +\ + + + + + + + +char + + + + + + + + + + + +${ + + + + + + + +escapeChars + + + + + + + +} + + + + + + + + + + + + + + + + + + +' + + + + + + + + + + +id + + + + + + + + + + + + + + + +\ + + + + + + + + +` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +\ + + + + + + + + +` + + + + + + + + + + + + + + + +\ + + + + + + + + +` + + + + + + + + +[^`\\] + + + + + + + + + + +\ + + + + + + + +char + + + + + + + + + + + +${ + + + + + + + +escapeIdChars + + + + + + + +} + + + + + + + + + + + + + + + + + + + + + + + + + + +\ + + + + + + + + +` + + + + + + + + + + + + + + + + diff --git a/index.js b/index.js index 4ef5944..0338d9e 100644 --- a/index.js +++ b/index.js @@ -1 +1,2 @@ module.exports = require('./lib/SqlString'); +module.exports.sql = require('./lib/Template'); diff --git a/lib/SqlString.js b/lib/SqlString.js index 419adec..d9eb502 100644 --- a/lib/SqlString.js +++ b/lib/SqlString.js @@ -14,6 +14,13 @@ var CHARS_ESCAPE_MAP = { '\'' : '\\\'', '\\' : '\\\\' }; +// \ does not escape backquotes inside ID literals. +var ONE_ID_PATTERN = '`(?:[^`]|``)+`'; +// One or more Identifiers separated by dots. +var QUALIFIED_ID_REGEXP = new RegExp( + '^' + ONE_ID_PATTERN + '(?:[.]' + ONE_ID_PATTERN + ')*$'); +// One Identifier without separating dots. +var UNQUALIFIED_ID_REGEXP = new RegExp('^' + ONE_ID_PATTERN + '$'); SqlString.escapeId = function escapeId(val, forbidQualified) { if (Array.isArray(val)) { @@ -24,6 +31,16 @@ SqlString.escapeId = function escapeId(val, forbidQualified) { } return sql; + } else if (val && typeof val.toSqlString === 'function') { + // If it corresponds to an identifier token, let it through. + var sqlString = val.toSqlString(); + if ((forbidQualified ? UNQUALIFIED_ID_REGEXP : QUALIFIED_ID_REGEXP).test(sqlString)) { + return sqlString; + } else { + throw new TypeError( + 'raw sql reached ?? or escapeId but is not an identifier: ' + + sqlString); + } } else if (forbidQualified) { return '`' + String(val).replace(ID_GLOBAL_REGEXP, '``') + '`'; } else { @@ -146,7 +163,7 @@ SqlString.dateToString = function dateToString(date, timeZone) { dt.setTime(dt.getTime() + (tz * 60000)); } - year = dt.getUTCFullYear(); + year = dt.getUTCFullYear(); month = dt.getUTCMonth() + 1; day = dt.getUTCDate(); hour = dt.getUTCHours(); @@ -193,6 +210,17 @@ SqlString.raw = function raw(sql) { }; }; +SqlString.identifier = function identifier(id, forbidQualified) { + if (typeof id !== 'string') { + throw new TypeError('argument id must be a string'); + } + + var idToken = SqlString.escapeId(id, forbidQualified); + return { + toSqlString: function toSqlString() { return idToken; } + }; +}; + function escapeString(val) { var chunkIndex = CHARS_GLOBAL_REGEXP.lastIndex = 0; var escapedVal = ''; diff --git a/lib/Template.js b/lib/Template.js new file mode 100644 index 0000000..e42969d --- /dev/null +++ b/lib/Template.js @@ -0,0 +1,32 @@ +try { + module.exports = require('./es6/Template'); +} catch (ignored) { + // ES6 code failed to load. + // + // This happens in Node runtimes with versions < 6. + // Since those runtimes won't parse template tags, we + // fallback to an equivalent API that assumes no calls + // are template tag calls. + // + // Clients that need to work on older Node runtimes + // should not use any part of this API except + // calledAsTemplateTagQuick unless that function has + // returned true. + + // eslint-disable-next-line no-unused-vars + module.exports = function (sqlStrings) { + // This might be reached if client code is transpiled down to + // ES5 but this module is not. + throw new Error('ES6 features not supported'); + }; + /** + * @param {*} firstArg The first argument to the function call. + * @param {number} nArgs The number of arguments pass to the function call. + * + * @return {boolean} always false in ES<6 compatibility mode. + */ + // eslint-disable-next-line no-unused-vars + module.exports.calledAsTemplateTagQuick = function (firstArg, nArgs) { + return false; + }; +} diff --git a/lib/es6/.eslintrc b/lib/es6/.eslintrc new file mode 100644 index 0000000..6f1e456 --- /dev/null +++ b/lib/es6/.eslintrc @@ -0,0 +1,3 @@ +{ + "parserOptions": { "ecmaVersion": 6 } +} diff --git a/lib/es6/Lexer.js b/lib/es6/Lexer.js new file mode 100644 index 0000000..7851657 --- /dev/null +++ b/lib/es6/Lexer.js @@ -0,0 +1,109 @@ +// 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 + +exports.makeLexer = makeLexer; + +const WS = '[\\t\\r\\n ]'; +const PREFIX_BEFORE_DELIMITER = new RegExp( + '^(?:' + + ( + // Comment + // https://dev.mysql.com/doc/refman/5.7/en/comments.html + // https://dev.mysql.com/doc/refman/5.7/en/ansi-diff-comments.html + // If we do not see a newline at the end of a comment, then it is + // a concatenation hazard; a fragment concatened at the end would + // start in a comment context. + '--(?=' + WS + ')[^\\r\\n]*[\r\n]' + + '|#[^\\r\\n]*[\r\n]' + + '|/[*][\\s\\S]*?[*]/' + ) + + '|' + + ( + // Run of non-comment non-string starts + '(?:[^\'"`\\-/#]|-(?!-' + WS + ')|/(?![*]))' + ) + + ')*'); +const DELIMITED_BODIES = { + '\'' : /^(?:[^'\\]|\\[\s\S]|'')*/, + '"' : /^(?:[^"\\]|\\[\s\S]|"")*/, + '`' : /^(?:[^`\\]|\\[\s\S]|``)*/ +}; + +/** + * 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]); + for (let i = 0; i < dyn.length; ++i) { + message += JSON.stringify(dyn[i]) + strs[i + 1]; + } + return message; +} + +/** + * Returns a stateful 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; + 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); + // Match must be defined since all possible values of pattern have + // an outer Kleene-* and no postcondition so will fallback to matching + // the empty string. + 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; + }; +} diff --git a/lib/es6/README.md b/lib/es6/README.md new file mode 100644 index 0000000..a05f6f4 --- /dev/null +++ b/lib/es6/README.md @@ -0,0 +1,4 @@ +The source files herein use ES6 features and are loaded optimistically. + +Calls that `require` them from source files in the parent directory +should be prepared for parsing to fail on EcmaScript engines. diff --git a/lib/es6/Template.js b/lib/es6/Template.js new file mode 100644 index 0000000..12c329a --- /dev/null +++ b/lib/es6/Template.js @@ -0,0 +1,123 @@ +// This file uses es6 features and is loaded optimistically. + +const SqlString = require('../SqlString'); +const { + calledAsTemplateTagQuick, + memoizedTagFunction, + trimCommonWhitespaceFromLines +} = require('template-tag-common'); +const { makeLexer } = require('./Lexer'); + +const LITERAL_BACKTICK_FIXUP_PATTERN = /((?:[^\\]|\\[^\`])+)|\\(\`)(?!\`)/g; + +/** + * Trims common whitespace and converts escaped backticks + * to backticks as appropriate. + * + * @param {!Array.} strings a valid TemplateObject. + * @return {!Array.} the adjusted raw strings. + */ +function prepareStrings(strings) { + const raw = trimCommonWhitespaceFromLines(strings).raw.slice(); + for (let i = 0, n = raw.length; i < n; ++i) { + // Convert \` to ` but leave \\` alone. + raw[i] = raw[i].replace(LITERAL_BACKTICK_FIXUP_PATTERN, '$1$2'); + } + return raw; +} + +/** + * Analyzes the static parts of the tag content. + * + * @param {!Array.} strings a valid TemplateObject. + * @return { !{ + * delimiters : !Array., + * chunks: !Array. + * } } + * A record like { delimiters, chunks } + * where delimiter is a contextual cue and chunk is + * the adjusted raw text. + */ +function computeStatic (strings) { + const chunks = prepareStrings(strings); + const lexer = makeLexer(); + + const delimiters = []; + let delimiter = null; + for (let i = 0, len = chunks.length; i < len; ++i) { + const chunk = String(chunks[i]); + const newDelimiter = lexer(chunk); + delimiters.push(newDelimiter); + delimiter = newDelimiter; + } + + if (delimiter) { + throw new Error(`Unclosed quoted string: ${delimiter}`); + } + + return { delimiters, chunks }; +} + +function interpolateSqlIntoFragment ( + { stringifyObjects, timeZone, forbidQualified }, + { delimiters, chunks }, + strings, values) { + // A buffer to accumulate output. + let [ result ] = chunks; + for (let i = 1, len = chunks.length; i < len; ++i) { + const chunk = chunks[i]; + // The count of values must be 1 less than the surrounding + // chunks of literal text. + const delimiter = delimiters[i - 1]; + const value = values[i - 1]; + + let escaped = delimiter + ? escapeDelimitedValue(value, delimiter, timeZone, forbidQualified) + : defangMergeHazard( + result, + SqlString.escape(value, stringifyObjects, timeZone), + chunk); + + result += escaped + chunk; + } + + return SqlString.raw(result); +} + +function escapeDelimitedValue (value, delimiter, timeZone, forbidQualified) { + if (delimiter === '`') { + return SqlString.escapeId(value, forbidQualified).replace(/^`|`$/g, ''); + } + if (Buffer.isBuffer(value)) { + value = value.toString('binary'); + } + const escaped = SqlString.escape(String(value), true, timeZone); + return escaped.substring(1, escaped.length - 1); +} + +function defangMergeHazard (before, escaped, after) { + const escapedLast = escaped[escaped.length - 1]; + if ('\"\'`'.indexOf(escapedLast) < 0) { + // Not a merge hazard. + return escaped; + } + + let escapedSetOff = escaped; + const lastBefore = before[before.length - 1]; + if (escapedLast === escaped[0] && escapedLast === lastBefore) { + escapedSetOff = ' ' + escapedSetOff; + } + if (escapedLast === after[0]) { + escapedSetOff += ' '; + } + return escapedSetOff; +} + +/** + * Template tag function that contextually autoescapes values + * producing a SqlFragment. + */ +const sql = memoizedTagFunction(computeStatic, interpolateSqlIntoFragment); +sql.calledAsTemplateTagQuick = calledAsTemplateTagQuick; + +module.exports = sql; diff --git a/package.json b/package.json index 2c951a3..d85c0e7 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "sql escape" ], "repository": "mysqljs/sqlstring", + "dependencies": { + "template-tag-common": "3.0.4" + }, "devDependencies": { "beautify-benchmark": "0.2.4", "benchmark": "2.1.4", diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..0d97e5e --- /dev/null +++ b/test/README.md @@ -0,0 +1,12 @@ +# Unit tests for sqlstring + +## Running tests on older node versions + +We rely on Travis CI to check compatibility with older Node runtimes. + +To locally run tests on an older runtime, for example `0.12`: + +```sh +$ npm install --no-save npx +$ ./node_modules/.bin/npx node@0.12 test/run.js +``` diff --git a/test/unit/es6/.eslintrc b/test/unit/es6/.eslintrc new file mode 100644 index 0000000..6f1e456 --- /dev/null +++ b/test/unit/es6/.eslintrc @@ -0,0 +1,3 @@ +{ + "parserOptions": { "ecmaVersion": 6 } +} diff --git a/test/unit/es6/Lexer.js b/test/unit/es6/Lexer.js new file mode 100644 index 0000000..7538801 --- /dev/null +++ b/test/unit/es6/Lexer.js @@ -0,0 +1,79 @@ +// This file uses es6 features and is loaded conditionally. + +var assert = require('assert'); +var test = require('utest'); +var Lexer = require('../../../lib/es6/Lexer'); + +function tokens (...chunks) { + const lexer = Lexer.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 () { + assert.equal(tokens(''), '_'); + }, + 'hash comments': function () { + assert.equal(tokens(' # "foo\n', ''), '_,_'); + }, + 'dash comments': function () { + assert.equal(tokens(' -- \'foo\n', ''), '_,_'); + }, + 'dash dash participates in number literal': function () { + assert.equal(tokens('SELECT (1--1) + "', '"'), '",_'); + }, + 'block comments': function () { + assert.equal(tokens(' /* `foo */', ''), '_,_'); + }, + 'dq': function () { + 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 () { + 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 () { + 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 `\\`', '`'), '`,_'); + }, + 'replay error': function () { + const lexer = Lexer.makeLexer(); + assert.equal(lexer('SELECT '), null); + assert.throws( + () => lexer(' # '), + /^Error: Expected delimiter at " # "$/); + // Providing more input throws the same error. + assert.throws( + () => lexer(' '), + /^Error: Expected delimiter at " # "$/); + }, + 'unfinished escape squence': function () { + const lexer = Lexer.makeLexer(); + assert.throws( + () => lexer('SELECT "\\'), + /^Error: Expected "\\\\" at "\\\\"$/); + } +}); diff --git a/test/unit/es6/Template.js b/test/unit/es6/Template.js new file mode 100644 index 0000000..4968720 --- /dev/null +++ b/test/unit/es6/Template.js @@ -0,0 +1,212 @@ +// This file uses es6 features and is loaded conditionally. + +var assert = require('assert'); +var SqlString = require('../../../'); +var test = require('utest'); + +var sql = SqlString.sql; + +function runTagTest (golden, test) { + // Run multiply to test memoization bugs. + for (let i = 3; --i >= 0;) { + let result = test(); + if (typeof result.toSqlString !== 'string') { + result = result.toSqlString(); + } else { + throw new Error(`Expected raw not ${result}`); + } + assert.equal(result, golden); + } +} + +test('template tag', { + 'numbers': function () { + runTagTest( + 'SELECT 2', + () => sql`SELECT ${1 + 1}`); + }, + 'date': function () { + const date = new Date(Date.UTC(2000, 0, 1, 0, 0, 0)); + runTagTest( + `SELECT '2000-01-01 00:00:00.000'`, + () => sql({ timeZone: 'GMT' })`SELECT ${date}`); + }, + 'string': function () { + runTagTest( + `SELECT 'Hello, World!\\n'`, + () => sql`SELECT ${'Hello, World!\n'}`); + }, + 'stringify': function () { + const obj = { + Hello : 'World!', + toString : function () { + return 'Hello, World!'; + } + }; + runTagTest( + `SELECT 'Hello, World!'`, + () => sql({ stringifyObjects: true })`SELECT ${obj}`); + runTagTest( + 'SELECT * FROM t WHERE `Hello` = \'World!\'', + () => sql({ stringifyObjects: false })`SELECT * FROM t WHERE ${obj}`); + }, + 'identifier': function () { + runTagTest( + 'SELECT `foo`', + () => sql`SELECT ${SqlString.identifier('foo')}`); + }, + 'blob': function () { + runTagTest( + 'SELECT "\x1f8p\xbe\\\'OlI\xb3\xe3\\Z\x0cg(\x95\x7f"', + () => + sql`SELECT "${Buffer.from('1f3870be274f6c49b3e31a0c6728957f', 'hex')}"` + ); + }, + 'null': function () { + runTagTest( + 'SELECT NULL', + () => + sql`SELECT ${null}` + ); + }, + 'undefined': function () { + runTagTest( + 'SELECT NULL', + () => + sql`SELECT ${undefined}` + ); + }, + 'negative zero': function () { + runTagTest( + 'SELECT (1 / 0)', + () => + sql`SELECT (1 / ${-0})` + ); + }, + 'raw': function () { + const raw = SqlString.raw('1 + 1'); + runTagTest( + `SELECT 1 + 1`, + () => sql`SELECT ${raw}`); + }, + 'string in dq string': function () { + runTagTest( + `SELECT "Hello, World!\\n"`, + () => sql`SELECT "Hello, ${'World!'}\n"`); + }, + 'string in sq string': function () { + runTagTest( + `SELECT 'Hello, World!\\n'`, + () => 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?"`, + () => sql`SELECT '${'Hello'}', "World?"`); + }, + 'string before string in string': function () { + runTagTest( + `SELECT 'Hello?', 'World?'`, + () => sql`SELECT 'Hello?', '${'World?'}'`); + }, + 'number after string in string': function () { + runTagTest( + `SELECT 'Hello?', 123`, + () => sql`SELECT '${'Hello?'}', ${123}`); + }, + 'number before string in string': function () { + runTagTest( + `SELECT 123, 'World?'`, + () => sql`SELECT ${123}, '${'World?'}'`); + }, + 'string in identifier': function () { + runTagTest( + 'SELECT `foo`', + () => sql`SELECT \`${'foo'}\``); + }, + 'identifier in identifier': function () { + runTagTest( + 'SELECT `foo`', + () => sql`SELECT \`${SqlString.identifier('foo')}\``); + }, + 'plain quoted identifier': function () { + runTagTest( + 'SELECT `ID`', + () => sql`SELECT \`ID\``); + }, + 'backquotes in identifier': function () { + runTagTest( + 'SELECT `\\\\`', + () => sql`SELECT \`\\\``); + const strings = ['SELECT `\\\\`']; + strings.raw = strings.slice(); + runTagTest('SELECT `\\\\`', () => sql(strings)); + }, + 'backquotes in strings': function () { + runTagTest( + 'SELECT "`\\\\", \'`\\\\\'', + () => sql`SELECT "\`\\", '\`\\'`); + }, + 'number in identifier': function () { + runTagTest( + 'SELECT `foo_123`', + () => sql`SELECT \`foo_${123}\``); + }, + 'array': function () { + const id = SqlString.identifier('foo'); + const frag = SqlString.raw('1 + 1'); + const values = [ 123, 'foo', id, frag ]; + runTagTest( + "SELECT X FROM T WHERE X IN (123, 'foo', `foo`, 1 + 1)", + () => sql`SELECT X FROM T WHERE X IN (${values})`); + }, + 'unclosed-sq': function () { + assert.throws(() => sql`SELECT '${'foo'}`); + }, + 'unclosed-dq': function () { + assert.throws(() => sql`SELECT "foo`); + }, + 'unclosed-bq': function () { + assert.throws(() => sql`SELECT \`${'foo'}`); + }, + 'unclosed-comment': function () { + // Ending in a comment is a concatenation hazard. + // See comments in lib/es6/Lexer.js. + assert.throws(() => sql`SELECT (${0}) -- comment`); + }, + 'merge-word-string': function () { + runTagTest( + `SELECT utf8'foo'`, + () => sql`SELECT utf8${'foo'}`); + }, + 'merge-string-string': function () { + runTagTest( + // Adjacent string tokens are concatenated, but 'a''b' is a + // 3-char string with a single-quote in the middle. + `SELECT 'a' 'b'`, + () => sql`SELECT ${'a'}${'b'}`); + }, + 'merge-bq-bq': function () { + runTagTest( + 'SELECT `a` `b`', + () => sql`SELECT ${SqlString.identifier('a')}${SqlString.identifier('b')}`); + }, + 'merge-static-string-string': function () { + runTagTest( + `SELECT 'a' 'b'`, + () => sql`SELECT 'a'${'b'}`); + }, + 'merge-string-static-string': function () { + runTagTest( + `SELECT 'a' 'b'`, + () => sql`SELECT ${'a'}'b'`); + }, + 'not-a-merge-hazard': function () { + runTagTest( + `SELECT 'a''b'`, + () => sql`SELECT 'a''b'`); + } +}); diff --git a/test/unit/es6/canary.js b/test/unit/es6/canary.js new file mode 100644 index 0000000..ce5556a --- /dev/null +++ b/test/unit/es6/canary.js @@ -0,0 +1,7 @@ +// A minimal file that uses ES6 features which will fail to load on +// older browsers. +`I load on ES6`; + +module.exports = function usesRestArgs (...args) { + return args; +}; diff --git a/test/unit/test-Lexer.js b/test/unit/test-Lexer.js new file mode 100644 index 0000000..6b77334 --- /dev/null +++ b/test/unit/test-Lexer.js @@ -0,0 +1,12 @@ +var canRequireES6 = true; +try { + require('./es6/canary'); +} catch (ignored) { + canRequireES6 = false; +} + +if (canRequireES6) { + require('./es6/Lexer'); +} else { + console.info('Skipping ES6 tests for node_version %s', process.version); +} diff --git a/test/unit/test-SqlString.js b/test/unit/test-SqlString.js index 8a83387..64cc5dd 100644 --- a/test/unit/test-SqlString.js +++ b/test/unit/test-SqlString.js @@ -45,6 +45,12 @@ test('SqlString.escapeId', { 'nested arrays are flattened': function() { assert.equal(SqlString.escapeId(['a', ['b', ['t.c']]]), '`a`, `b`, `t`.`c`'); + }, + + 'rejects qualified id': function() { + assert.throws(function() { + SqlString.escapeId(SqlString.identifier('id1.id2', false), true); + }); } }); @@ -262,6 +268,21 @@ test('SqlString.format', { assert.equal(sql, "'foo' or ??? and 'bar'"); }, + 'double quest marks passes pre-escaped id': function () { + var sql = SqlString.format( + 'SELECT ?? FROM ?? WHERE id = ?', + [SqlString.identifier('table.id'), SqlString.identifier('table'), 42]); + assert.equal(sql, 'SELECT `table`.`id` FROM `table` WHERE id = 42'); + }, + + 'double quest marks rejects invalid raw': function () { + assert.throws(function () { + SqlString.format( + 'SELECT * FROM ?? WHERE id = 42', + [SqlString.raw('NOW()')]); + }); + }, + 'extra question marks are left untouched': function() { var sql = SqlString.format('? and ?', ['a']); assert.equal(sql, "'a' and ?"); @@ -334,3 +355,50 @@ test('SqlString.raw', { assert.equal(SqlString.raw("NOW() AS 'current_time'").toSqlString(), "NOW() AS 'current_time'"); } }); + +test('SqlString.identifier', { + 'creates object': function() { + assert.equal(typeof SqlString.identifier('i'), 'object'); + }, + + 'rejects number': function() { + assert.throws(function () { + SqlString.identifier(42); + }); + }, + + 'rejects undefined': function() { + assert.throws(function () { + SqlString.identifier(); + }); + }, + + 'object has toSqlString': function() { + assert.equal( + typeof SqlString.identifier('NOW()').toSqlString, + 'function'); + }, + + 'toSqlString returns escaped id': function() { + assert.equal( + SqlString.identifier('Hello, World!').toSqlString(), + '`Hello, World!`'); + }, + + 'backticks escaped': function() { + assert.equal(SqlString.identifier('I`m').toSqlString(), '`I``m`'); + }, + + 'escape() does not re-escape': function() { + assert.equal(SqlString.escape(SqlString.identifier('I`m')), '`I``m`'); + }, + + 'qualified': function() { + assert.equal(SqlString.identifier('id1.id2').toSqlString(), '`id1`.`id2`'); + assert.equal(SqlString.identifier('id1.id2', false).toSqlString(), '`id1`.`id2`'); + }, + + 'rejects forbidQualified': function() { + assert.equal(SqlString.identifier('id1.id2', true).toSqlString(), '`id1.id2`'); + } +}); diff --git a/test/unit/test-Template.js b/test/unit/test-Template.js new file mode 100644 index 0000000..9bf0b7b --- /dev/null +++ b/test/unit/test-Template.js @@ -0,0 +1,43 @@ +var assert = require('assert'); +var SqlString = require('../../'); +var test = require('utest'); + +var canRequireES6 = true; +try { + require('./es6/canary'); +} catch (ignored) { + canRequireES6 = false; +} + +if (canRequireES6) { + require('./es6/Template'); +} else { + console.info('Skipping ES6 tests for node_version %s', process.version); + + test('Template fallback', { + 'fallback sql': function () { + var strings = ['SELECT ', '']; + strings.raw = ['SELECT ', '']; + assert.throws( + function () { + SqlString.sql(strings, 42); + }); + } + }); +} + +var sql = SqlString.sql; + +// Regardless of whether ES6 is availale, sql.calledAsTemplateTagQuick +// should return false for non-tag calling conventions. +test('sql.calledAsTemplateTagQuick', { + 'zero arguments': function () { + assert.equal(sql.calledAsTemplateTagQuick(undefined, 0), false); + }, + 'some arguments': function () { + assert.equal(sql.calledAsTemplateTagQuick(1, 2), false); + }, + 'string array first': function () { + assert.equal(sql.calledAsTemplateTagQuick([''], 2), false); + } +});