Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit fc306fa

Browse files
committedMar 7, 2018
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 mysqljs/mysql#1926
1 parent 8f193ca commit fc306fa

18 files changed

+1683
-3
lines changed
 

‎README.md

+88-2
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,16 @@ console.log(sql); // UPDATE posts SET modified = CURRENT_TIMESTAMP() WHERE id =
102102
```
103103

104104
To generate objects with a `toSqlString` method, the `SqlString.raw()` method can
105-
be used. This creates an object that will be left un-touched when using in a `?`
105+
be used. This creates an object that will be left un-touched when used in a `?`
106106
placeholder, useful for using functions as dynamic values:
107107

108108
**Caution** The string provided to `SqlString.raw()` will skip all escaping
109109
functions when used, so be careful when passing in unvalidated input.
110110

111+
Similarly, `SqlString.identifier(id, forbidQualified)` creates an object with a
112+
`toSqlString` method that returns `SqlString.escapeId(id, forbidQualified)`.
113+
Its result is not re-escaped when used in a `?` or `??` placeholder.
114+
111115
```js
112116
var CURRENT_TIMESTAMP = SqlString.raw('CURRENT_TIMESTAMP()');
113117
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);
150154
console.log(sql); // SELECT * FROM posts ORDER BY `date.2`
151155
```
152156

157+
If `escapeId` receives an object with a `toSqlString` method, then `escapeId` uses
158+
that method's result after coercing it to a string.
159+
160+
```js
161+
var sorter = SqlString.identifier('date'); // ({ toSqlString: () => '`date`' })
162+
var sql = 'SELECT * FROM posts ORDER BY ' + sqlString.escapeId(sorter);
163+
console.log(sql); // SELECT * FROM posts ORDER BY `date`
164+
```
165+
153166
Alternatively, you can use `??` characters as placeholders for identifiers you would
154167
like to have escaped like this:
155168

@@ -161,7 +174,8 @@ console.log(sql); // SELECT `username`, `email` FROM `users` WHERE id = 1
161174
```
162175
**Please note that this last character sequence is experimental and syntax might change**
163176

164-
When you pass an Object to `.escape()` or `.format()`, `.escapeId()` is used to avoid SQL injection in object keys.
177+
When you pass an Object to `.escape()` or `.format()`, `.escapeId()`
178+
is used to avoid SQL injection in object keys.
165179

166180
### Formatting queries
167181

@@ -191,6 +205,78 @@ var sql = SqlString.format('UPDATE ?? SET ? WHERE `id` = ?', ['users', data,
191205
console.log(sql); // UPDATE `users` SET `email` = 'foobar@example.com', `modified` = NOW() WHERE `id` = 1
192206
```
193207

208+
### ES6 Template Tag Support
209+
210+
`SqlString.sql` works as a template tag in Node versions that support ES6 features
211+
(node runtime versions 6 and later).
212+
213+
```es6
214+
var column = 'users';
215+
var userId = 1;
216+
var data = { email: 'foobar@example.com', modified: SqlString.raw('NOW()') };
217+
var fromFormat = SqlString.format('UPDATE ?? SET ? WHERE `id` = ?', [column, data, userId]);
218+
var fromTag = SqlString.sql`UPDATE \`${column}\` SET ${data} WHERE \`id\` = ${userId}`;
219+
220+
console.log(fromFormat);
221+
console.log(fromTag.toSqlString());
222+
// Both emit:
223+
// UPDATE `users` SET `email` = 'foobar@example.com', `modified` = NOW() WHERE `id` = 1
224+
```
225+
226+
227+
There are some differences between `SqlString.format` and `SqlString.raw`:
228+
229+
* The `SqlString.sql` tag returns a raw chunk SQL as if by `SqlString.raw`,
230+
whereas `SqlString.format` returns a string.
231+
This allows chaining:
232+
```es6
233+
let data = { a: 1 };
234+
let whereClause = SqlString.sql`WHERE ${data}`;
235+
SqlString.sql`SELECT * FROM TABLE ${whereClause}`.toSqlString();
236+
// SELECT * FROM TABLE WHERE `a` = 1
237+
```
238+
* An interpolation in a quoted string will not insert excess quotes:
239+
```es6
240+
SqlString.sql`SELECT '${ 'foo' }' `.toSqlString() === `SELECT 'foo' `;
241+
SqlString.sql`SELECT ${ 'foo' } `.toSqlString() === `SELECT 'foo' `;
242+
SqlString.format("SELECT '?' ", ['foo']) === `SELECT ''foo'' `;
243+
```
244+
This means that you can interpolate a string into an ID thus:
245+
```es6
246+
SqlString.sql`SELECT * FROM \`${ 'table' }\``.toSqlString() === 'SELECT * FROM `table`'
247+
SqlString.format('SELECT * FROM ??', ['table']) === 'SELECT * FROM `table`'
248+
```
249+
* Backticks end a template tag, so you need to escape backticks.
250+
```es6
251+
SqlString.sql`SELECT \`${ 'id' }\` FROM \`TABLE\``.toSqlString()
252+
=== 'SELECT `id` FROM `TABLE`'
253+
```
254+
* Other escape sequences are raw.
255+
```es6
256+
SqlString.sql`SELECT "\n"`.toSqlString() === 'SELECT "\\n"'
257+
SqlString.format('SELECT "\n"', []) === 'SELECT "\n"'
258+
SqlString.format(String.raw`SELECT "\n"`, []) === 'SELECT "\\n"'
259+
```
260+
* `SqlString.format` takes options at the end, but `SqlString.sql`
261+
takes an options object in a separate call.
262+
```es6
263+
let timeZone = 'GMT';
264+
let date = new Date(Date.UTC(2000, 0, 1));
265+
SqlString.sql({ timeZone })`SELECT ${date}`.toSqlString() ===
266+
'SELECT \'2000-01-01 00:00:00.000\'';
267+
SqlString.format('SELECT ?', [date], false, timezone) ===
268+
'SELECT \'2000-01-01 00:00:00.000\'';
269+
```
270+
The options object can contain any of
271+
`{ stringifyObjects, timeZone, forbidQualified }` which have the
272+
same meaning as when used with other `SqlString` APIs.
273+
274+
`SqlString.sql` handles `${...}` inside quoted strings as if the tag
275+
matched the following grammar:
276+
277+
[![Railroad Diagram](docs/sql-railroad.svg)](docs/sql-railroad.svg)
278+
279+
194280
## License
195281

196282
[MIT](LICENSE)

‎docs/sql-railroad.svg

+855
Loading

‎index.js

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
module.exports = require('./lib/SqlString');
2+
module.exports.sql = require('./lib/Template');

‎lib/SqlString.js

+29-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ var CHARS_ESCAPE_MAP = {
1414
'\'' : '\\\'',
1515
'\\' : '\\\\'
1616
};
17+
// \ does not escape backquotes inside ID literals.
18+
var ONE_ID_PATTERN = '`(?:[^`]|``)+`';
19+
// One or more Identifiers separated by dots.
20+
var QUALIFIED_ID_REGEXP = new RegExp(
21+
'^' + ONE_ID_PATTERN + '(?:[.]' + ONE_ID_PATTERN + ')*$');
22+
// One Identifier without separating dots.
23+
var UNQUALIFIED_ID_REGEXP = new RegExp('^' + ONE_ID_PATTERN + '$');
1724

1825
SqlString.escapeId = function escapeId(val, forbidQualified) {
1926
if (Array.isArray(val)) {
@@ -24,6 +31,16 @@ SqlString.escapeId = function escapeId(val, forbidQualified) {
2431
}
2532

2633
return sql;
34+
} else if (val && typeof val.toSqlString === 'function') {
35+
// If it corresponds to an identifier token, let it through.
36+
var sqlString = val.toSqlString();
37+
if ((forbidQualified ? UNQUALIFIED_ID_REGEXP : QUALIFIED_ID_REGEXP).test(sqlString)) {
38+
return sqlString;
39+
} else {
40+
throw new TypeError(
41+
'raw sql reached ?? or escapeId but is not an identifier: ' +
42+
sqlString);
43+
}
2744
} else if (forbidQualified) {
2845
return '`' + String(val).replace(ID_GLOBAL_REGEXP, '``') + '`';
2946
} else {
@@ -146,7 +163,7 @@ SqlString.dateToString = function dateToString(date, timeZone) {
146163
dt.setTime(dt.getTime() + (tz * 60000));
147164
}
148165

149-
year = dt.getUTCFullYear();
166+
year = dt.getUTCFullYear();
150167
month = dt.getUTCMonth() + 1;
151168
day = dt.getUTCDate();
152169
hour = dt.getUTCHours();
@@ -193,6 +210,17 @@ SqlString.raw = function raw(sql) {
193210
};
194211
};
195212

213+
SqlString.identifier = function identifier(id, forbidQualified) {
214+
if (typeof id !== 'string') {
215+
throw new TypeError('argument id must be a string');
216+
}
217+
218+
var idToken = SqlString.escapeId(id, forbidQualified);
219+
return {
220+
toSqlString: function toSqlString() { return idToken; }
221+
};
222+
};
223+
196224
function escapeString(val) {
197225
var chunkIndex = CHARS_GLOBAL_REGEXP.lastIndex = 0;
198226
var escapedVal = '';

‎lib/Template.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
try {
2+
module.exports = require('./es6/Template');
3+
} catch (ignored) {
4+
// ES6 code failed to load.
5+
//
6+
// This happens in Node runtimes with versions < 6.
7+
// Since those runtimes won't parse template tags, we
8+
// fallback to an equivalent API that assumes no calls
9+
// are template tag calls.
10+
//
11+
// Clients that need to work on older Node runtimes
12+
// should not use any part of this API except
13+
// calledAsTemplateTagQuick unless that function has
14+
// returned true.
15+
16+
// eslint-disable-next-line no-unused-vars
17+
module.exports = function (sqlStrings) {
18+
// This might be reached if client code is transpiled down to
19+
// ES5 but this module is not.
20+
throw new Error('ES6 features not supported');
21+
};
22+
/**
23+
* @param {*} firstArg The first argument to the function call.
24+
* @param {number} nArgs The number of arguments pass to the function call.
25+
*
26+
* @return {boolean} always false in ES<6 compatibility mode.
27+
*/
28+
// eslint-disable-next-line no-unused-vars
29+
module.exports.calledAsTemplateTagQuick = function (firstArg, nArgs) {
30+
return false;
31+
};
32+
}

‎lib/es6/.eslintrc

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"parserOptions": { "ecmaVersion": 6 }
3+
}

‎lib/es6/Lexer.js

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// A simple lexer for SQL.
2+
// SQL has many divergent dialects with subtly different
3+
// conventions for string escaping and comments.
4+
// This just attempts to roughly tokenize MySQL's specific variant.
5+
// See also
6+
// https://www.w3.org/2005/05/22-SPARQL-MySQL/sql_yacc
7+
// https://github.com/twitter/mysql/blob/master/sql/sql_lex.cc
8+
// https://dev.mysql.com/doc/refman/5.7/en/string-literals.html
9+
10+
// "--" followed by whitespace starts a line comment
11+
// "#"
12+
// "/*" starts an inline comment ended at first "*/"
13+
// \N means null
14+
// Prefixed strings x'...' is a hex string, b'...' is a binary string, ....
15+
// '...', "..." are strings. `...` escapes identifiers.
16+
// doubled delimiters and backslash both escape
17+
// doubled delimiters work in `...` identifiers
18+
19+
exports.makeLexer = makeLexer;
20+
21+
const WS = '[\\t\\r\\n ]';
22+
const PREFIX_BEFORE_DELIMITER = new RegExp(
23+
'^(?:' +
24+
(
25+
// Comment
26+
// https://dev.mysql.com/doc/refman/5.7/en/comments.html
27+
// https://dev.mysql.com/doc/refman/5.7/en/ansi-diff-comments.html
28+
// If we do not see a newline at the end of a comment, then it is
29+
// a concatenation hazard; a fragment concatened at the end would
30+
// start in a comment context.
31+
'--(?=' + WS + ')[^\\r\\n]*[\r\n]' +
32+
'|#[^\\r\\n]*[\r\n]' +
33+
'|/[*][\\s\\S]*?[*]/'
34+
) +
35+
'|' +
36+
(
37+
// Run of non-comment non-string starts
38+
'(?:[^\'"`\\-/#]|-(?!-' + WS + ')|/(?![*]))'
39+
) +
40+
')*');
41+
const DELIMITED_BODIES = {
42+
'\'' : /^(?:[^'\\]|\\[\s\S]|'')*/,
43+
'"' : /^(?:[^"\\]|\\[\s\S]|"")*/,
44+
'`' : /^(?:[^`\\]|\\[\s\S]|``)*/
45+
};
46+
47+
/**
48+
* Template tag that creates a new Error with a message.
49+
* @param {!Array.<string>} strs a valid TemplateObject.
50+
* @return {string} A message suitable for the Error constructor.
51+
*/
52+
function msg (strs, ...dyn) {
53+
let message = String(strs[0]);
54+
for (let i = 0; i < dyn.length; ++i) {
55+
message += JSON.stringify(dyn[i]) + strs[i + 1];
56+
}
57+
return message;
58+
}
59+
60+
/**
61+
* Returns a stateful function that can be fed chunks of input and
62+
* which returns a delimiter context.
63+
*
64+
* @return {!function (string) : string}
65+
* a stateful function that takes a string of SQL text and
66+
* returns the context after it. Subsequent calls will assume
67+
* that context.
68+
*/
69+
function makeLexer () {
70+
let errorMessage = null;
71+
let delimiter = null;
72+
return (text) => {
73+
if (errorMessage) {
74+
// Replay the error message if we've already failed.
75+
throw new Error(errorMessage);
76+
}
77+
text = String(text);
78+
while (text) {
79+
const pattern = delimiter
80+
? DELIMITED_BODIES[delimiter]
81+
: PREFIX_BEFORE_DELIMITER;
82+
const match = pattern.exec(text);
83+
// Match must be defined since all possible values of pattern have
84+
// an outer Kleene-* and no postcondition so will fallback to matching
85+
// the empty string.
86+
let nConsumed = match[0].length;
87+
if (text.length > nConsumed) {
88+
const chr = text.charAt(nConsumed);
89+
if (delimiter) {
90+
if (chr === delimiter) {
91+
delimiter = null;
92+
++nConsumed;
93+
} else {
94+
throw new Error(
95+
errorMessage = msg`Expected ${chr} at ${text}`);
96+
}
97+
} else if (Object.hasOwnProperty.call(DELIMITED_BODIES, chr)) {
98+
delimiter = chr;
99+
++nConsumed;
100+
} else {
101+
throw new Error(
102+
errorMessage = msg`Expected delimiter at ${text}`);
103+
}
104+
}
105+
text = text.substring(nConsumed);
106+
}
107+
return delimiter;
108+
};
109+
}

‎lib/es6/README.md

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
The source files herein use ES6 features and are loaded optimistically.
2+
3+
Calls that `require` them from source files in the parent directory
4+
should be prepared for parsing to fail on EcmaScript engines.

‎lib/es6/Template.js

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// This file uses es6 features and is loaded optimistically.
2+
3+
const SqlString = require('../SqlString');
4+
const {
5+
calledAsTemplateTagQuick,
6+
memoizedTagFunction,
7+
trimCommonWhitespaceFromLines
8+
} = require('template-tag-common');
9+
const { makeLexer } = require('./Lexer');
10+
11+
const LITERAL_BACKTICK_FIXUP_PATTERN = /((?:[^\\]|\\[^\`])+)|\\(\`)(?!\`)/g;
12+
13+
/**
14+
* Trims common whitespace and converts escaped backticks
15+
* to backticks as appropriate.
16+
*
17+
* @param {!Array.<string>} strings a valid TemplateObject.
18+
* @return {!Array.<string>} the adjusted raw strings.
19+
*/
20+
function prepareStrings(strings) {
21+
const raw = trimCommonWhitespaceFromLines(strings).raw.slice();
22+
for (let i = 0, n = raw.length; i < n; ++i) {
23+
// Convert \` to ` but leave \\` alone.
24+
raw[i] = raw[i].replace(LITERAL_BACKTICK_FIXUP_PATTERN, '$1$2');
25+
}
26+
return raw;
27+
}
28+
29+
/**
30+
* Analyzes the static parts of the tag content.
31+
*
32+
* @param {!Array.<string>} strings a valid TemplateObject.
33+
* @return { !{
34+
* delimiters : !Array.<string>,
35+
* chunks: !Array.<string>
36+
* } }
37+
* A record like { delimiters, chunks }
38+
* where delimiter is a contextual cue and chunk is
39+
* the adjusted raw text.
40+
*/
41+
function computeStatic (strings) {
42+
const chunks = prepareStrings(strings);
43+
const lexer = makeLexer();
44+
45+
const delimiters = [];
46+
let delimiter = null;
47+
for (let i = 0, len = chunks.length; i < len; ++i) {
48+
const chunk = String(chunks[i]);
49+
const newDelimiter = lexer(chunk);
50+
delimiters.push(newDelimiter);
51+
delimiter = newDelimiter;
52+
}
53+
54+
if (delimiter) {
55+
throw new Error(`Unclosed quoted string: ${delimiter}`);
56+
}
57+
58+
return { delimiters, chunks };
59+
}
60+
61+
function interpolateSqlIntoFragment (
62+
{ stringifyObjects, timeZone, forbidQualified },
63+
{ delimiters, chunks },
64+
strings, values) {
65+
// A buffer to accumulate output.
66+
let [ result ] = chunks;
67+
for (let i = 1, len = chunks.length; i < len; ++i) {
68+
const chunk = chunks[i];
69+
// The count of values must be 1 less than the surrounding
70+
// chunks of literal text.
71+
const delimiter = delimiters[i - 1];
72+
const value = values[i - 1];
73+
74+
let escaped = delimiter
75+
? escapeDelimitedValue(value, delimiter, timeZone, forbidQualified)
76+
: defangMergeHazard(
77+
result,
78+
SqlString.escape(value, stringifyObjects, timeZone),
79+
chunk);
80+
81+
result += escaped + chunk;
82+
}
83+
84+
return SqlString.raw(result);
85+
}
86+
87+
function escapeDelimitedValue (value, delimiter, timeZone, forbidQualified) {
88+
if (delimiter === '`') {
89+
return SqlString.escapeId(value, forbidQualified).replace(/^`|`$/g, '');
90+
}
91+
if (Buffer.isBuffer(value)) {
92+
value = value.toString('binary');
93+
}
94+
const escaped = SqlString.escape(String(value), true, timeZone);
95+
return escaped.substring(1, escaped.length - 1);
96+
}
97+
98+
function defangMergeHazard (before, escaped, after) {
99+
const escapedLast = escaped[escaped.length - 1];
100+
if ('\"\'`'.indexOf(escapedLast) < 0) {
101+
// Not a merge hazard.
102+
return escaped;
103+
}
104+
105+
let escapedSetOff = escaped;
106+
const lastBefore = before[before.length - 1];
107+
if (escapedLast === escaped[0] && escapedLast === lastBefore) {
108+
escapedSetOff = ' ' + escapedSetOff;
109+
}
110+
if (escapedLast === after[0]) {
111+
escapedSetOff += ' ';
112+
}
113+
return escapedSetOff;
114+
}
115+
116+
/**
117+
* Template tag function that contextually autoescapes values
118+
* producing a SqlFragment.
119+
*/
120+
const sql = memoizedTagFunction(computeStatic, interpolateSqlIntoFragment);
121+
sql.calledAsTemplateTagQuick = calledAsTemplateTagQuick;
122+
123+
module.exports = sql;

‎package.json

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
"sql escape"
1919
],
2020
"repository": "mysqljs/sqlstring",
21+
"dependencies": {
22+
"template-tag-common": "3.0.4"
23+
},
2124
"devDependencies": {
2225
"beautify-benchmark": "0.2.4",
2326
"benchmark": "2.1.4",

‎test/README.md

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Unit tests for sqlstring
2+
3+
## Running tests on older node versions
4+
5+
We rely on Travis CI to check compatibility with older Node runtimes.
6+
7+
To locally run tests on an older runtime, for example `0.12`:
8+
9+
```sh
10+
$ npm install --no-save npx
11+
$ ./node_modules/.bin/npx node@0.12 test/run.js
12+
```

‎test/unit/es6/.eslintrc

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"parserOptions": { "ecmaVersion": 6 }
3+
}

‎test/unit/es6/Lexer.js

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// This file uses es6 features and is loaded conditionally.
2+
3+
var assert = require('assert');
4+
var test = require('utest');
5+
var Lexer = require('../../../lib/es6/Lexer');
6+
7+
function tokens (...chunks) {
8+
const lexer = Lexer.makeLexer();
9+
const out = [];
10+
for (let i = 0, len = chunks.length; i < len; ++i) {
11+
out.push(lexer(chunks[i]) || '_');
12+
}
13+
return out.join(',');
14+
}
15+
16+
test('template lexer', {
17+
'empty string': function () {
18+
assert.equal(tokens(''), '_');
19+
},
20+
'hash comments': function () {
21+
assert.equal(tokens(' # "foo\n', ''), '_,_');
22+
},
23+
'dash comments': function () {
24+
assert.equal(tokens(' -- \'foo\n', ''), '_,_');
25+
},
26+
'dash dash participates in number literal': function () {
27+
assert.equal(tokens('SELECT (1--1) + "', '"'), '",_');
28+
},
29+
'block comments': function () {
30+
assert.equal(tokens(' /* `foo */', ''), '_,_');
31+
},
32+
'dq': function () {
33+
assert.equal(tokens('SELECT "foo"'), '_');
34+
assert.equal(tokens('SELECT `foo`, "foo"'), '_');
35+
assert.equal(tokens('SELECT "', '"'), '",_');
36+
assert.equal(tokens('SELECT "x', '"'), '",_');
37+
assert.equal(tokens('SELECT "\'', '"'), '",_');
38+
assert.equal(tokens('SELECT "`', '"'), '",_');
39+
assert.equal(tokens('SELECT """', '"'), '",_');
40+
assert.equal(tokens('SELECT "\\"', '"'), '",_');
41+
},
42+
'sq': function () {
43+
assert.equal(tokens('SELECT \'foo\''), '_');
44+
assert.equal(tokens('SELECT `foo`, \'foo\''), '_');
45+
assert.equal(tokens('SELECT \'', '\''), '\',_');
46+
assert.equal(tokens('SELECT \'x', '\''), '\',_');
47+
assert.equal(tokens('SELECT \'"', '\''), '\',_');
48+
assert.equal(tokens('SELECT \'`', '\''), '\',_');
49+
assert.equal(tokens('SELECT \'\'\'', '\''), '\',_');
50+
assert.equal(tokens('SELECT \'\\\'', '\''), '\',_');
51+
},
52+
'bq': function () {
53+
assert.equal(tokens('SELECT `foo`'), '_');
54+
assert.equal(tokens('SELECT "foo", `foo`'), '_');
55+
assert.equal(tokens('SELECT `', '`'), '`,_');
56+
assert.equal(tokens('SELECT `x', '`'), '`,_');
57+
assert.equal(tokens('SELECT `\'', '`'), '`,_');
58+
assert.equal(tokens('SELECT `"', '`'), '`,_');
59+
assert.equal(tokens('SELECT ```', '`'), '`,_');
60+
assert.equal(tokens('SELECT `\\`', '`'), '`,_');
61+
},
62+
'replay error': function () {
63+
const lexer = Lexer.makeLexer();
64+
assert.equal(lexer('SELECT '), null);
65+
assert.throws(
66+
() => lexer(' # '),
67+
/^Error: Expected delimiter at " # "$/);
68+
// Providing more input throws the same error.
69+
assert.throws(
70+
() => lexer(' '),
71+
/^Error: Expected delimiter at " # "$/);
72+
},
73+
'unfinished escape squence': function () {
74+
const lexer = Lexer.makeLexer();
75+
assert.throws(
76+
() => lexer('SELECT "\\'),
77+
/^Error: Expected "\\\\" at "\\\\"$/);
78+
}
79+
});

‎test/unit/es6/Template.js

+212
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// This file uses es6 features and is loaded conditionally.
2+
3+
var assert = require('assert');
4+
var SqlString = require('../../../');
5+
var test = require('utest');
6+
7+
var sql = SqlString.sql;
8+
9+
function runTagTest (golden, test) {
10+
// Run multiply to test memoization bugs.
11+
for (let i = 3; --i >= 0;) {
12+
let result = test();
13+
if (typeof result.toSqlString !== 'string') {
14+
result = result.toSqlString();
15+
} else {
16+
throw new Error(`Expected raw not ${result}`);
17+
}
18+
assert.equal(result, golden);
19+
}
20+
}
21+
22+
test('template tag', {
23+
'numbers': function () {
24+
runTagTest(
25+
'SELECT 2',
26+
() => sql`SELECT ${1 + 1}`);
27+
},
28+
'date': function () {
29+
const date = new Date(Date.UTC(2000, 0, 1, 0, 0, 0));
30+
runTagTest(
31+
`SELECT '2000-01-01 00:00:00.000'`,
32+
() => sql({ timeZone: 'GMT' })`SELECT ${date}`);
33+
},
34+
'string': function () {
35+
runTagTest(
36+
`SELECT 'Hello, World!\\n'`,
37+
() => sql`SELECT ${'Hello, World!\n'}`);
38+
},
39+
'stringify': function () {
40+
const obj = {
41+
Hello : 'World!',
42+
toString : function () {
43+
return 'Hello, World!';
44+
}
45+
};
46+
runTagTest(
47+
`SELECT 'Hello, World!'`,
48+
() => sql({ stringifyObjects: true })`SELECT ${obj}`);
49+
runTagTest(
50+
'SELECT * FROM t WHERE `Hello` = \'World!\'',
51+
() => sql({ stringifyObjects: false })`SELECT * FROM t WHERE ${obj}`);
52+
},
53+
'identifier': function () {
54+
runTagTest(
55+
'SELECT `foo`',
56+
() => sql`SELECT ${SqlString.identifier('foo')}`);
57+
},
58+
'blob': function () {
59+
runTagTest(
60+
'SELECT "\x1f8p\xbe\\\'OlI\xb3\xe3\\Z\x0cg(\x95\x7f"',
61+
() =>
62+
sql`SELECT "${Buffer.from('1f3870be274f6c49b3e31a0c6728957f', 'hex')}"`
63+
);
64+
},
65+
'null': function () {
66+
runTagTest(
67+
'SELECT NULL',
68+
() =>
69+
sql`SELECT ${null}`
70+
);
71+
},
72+
'undefined': function () {
73+
runTagTest(
74+
'SELECT NULL',
75+
() =>
76+
sql`SELECT ${undefined}`
77+
);
78+
},
79+
'negative zero': function () {
80+
runTagTest(
81+
'SELECT (1 / 0)',
82+
() =>
83+
sql`SELECT (1 / ${-0})`
84+
);
85+
},
86+
'raw': function () {
87+
const raw = SqlString.raw('1 + 1');
88+
runTagTest(
89+
`SELECT 1 + 1`,
90+
() => sql`SELECT ${raw}`);
91+
},
92+
'string in dq string': function () {
93+
runTagTest(
94+
`SELECT "Hello, World!\\n"`,
95+
() => sql`SELECT "Hello, ${'World!'}\n"`);
96+
},
97+
'string in sq string': function () {
98+
runTagTest(
99+
`SELECT 'Hello, World!\\n'`,
100+
() => sql`SELECT 'Hello, ${'World!'}\n'`);
101+
},
102+
'string after string in string': function () {
103+
// The following tests check obliquely that '?' is not
104+
// interpreted as a prepared statement meta-character
105+
// internally.
106+
runTagTest(
107+
`SELECT 'Hello', "World?"`,
108+
() => sql`SELECT '${'Hello'}', "World?"`);
109+
},
110+
'string before string in string': function () {
111+
runTagTest(
112+
`SELECT 'Hello?', 'World?'`,
113+
() => sql`SELECT 'Hello?', '${'World?'}'`);
114+
},
115+
'number after string in string': function () {
116+
runTagTest(
117+
`SELECT 'Hello?', 123`,
118+
() => sql`SELECT '${'Hello?'}', ${123}`);
119+
},
120+
'number before string in string': function () {
121+
runTagTest(
122+
`SELECT 123, 'World?'`,
123+
() => sql`SELECT ${123}, '${'World?'}'`);
124+
},
125+
'string in identifier': function () {
126+
runTagTest(
127+
'SELECT `foo`',
128+
() => sql`SELECT \`${'foo'}\``);
129+
},
130+
'identifier in identifier': function () {
131+
runTagTest(
132+
'SELECT `foo`',
133+
() => sql`SELECT \`${SqlString.identifier('foo')}\``);
134+
},
135+
'plain quoted identifier': function () {
136+
runTagTest(
137+
'SELECT `ID`',
138+
() => sql`SELECT \`ID\``);
139+
},
140+
'backquotes in identifier': function () {
141+
runTagTest(
142+
'SELECT `\\\\`',
143+
() => sql`SELECT \`\\\``);
144+
const strings = ['SELECT `\\\\`'];
145+
strings.raw = strings.slice();
146+
runTagTest('SELECT `\\\\`', () => sql(strings));
147+
},
148+
'backquotes in strings': function () {
149+
runTagTest(
150+
'SELECT "`\\\\", \'`\\\\\'',
151+
() => sql`SELECT "\`\\", '\`\\'`);
152+
},
153+
'number in identifier': function () {
154+
runTagTest(
155+
'SELECT `foo_123`',
156+
() => sql`SELECT \`foo_${123}\``);
157+
},
158+
'array': function () {
159+
const id = SqlString.identifier('foo');
160+
const frag = SqlString.raw('1 + 1');
161+
const values = [ 123, 'foo', id, frag ];
162+
runTagTest(
163+
"SELECT X FROM T WHERE X IN (123, 'foo', `foo`, 1 + 1)",
164+
() => sql`SELECT X FROM T WHERE X IN (${values})`);
165+
},
166+
'unclosed-sq': function () {
167+
assert.throws(() => sql`SELECT '${'foo'}`);
168+
},
169+
'unclosed-dq': function () {
170+
assert.throws(() => sql`SELECT "foo`);
171+
},
172+
'unclosed-bq': function () {
173+
assert.throws(() => sql`SELECT \`${'foo'}`);
174+
},
175+
'unclosed-comment': function () {
176+
// Ending in a comment is a concatenation hazard.
177+
// See comments in lib/es6/Lexer.js.
178+
assert.throws(() => sql`SELECT (${0}) -- comment`);
179+
},
180+
'merge-word-string': function () {
181+
runTagTest(
182+
`SELECT utf8'foo'`,
183+
() => sql`SELECT utf8${'foo'}`);
184+
},
185+
'merge-string-string': function () {
186+
runTagTest(
187+
// Adjacent string tokens are concatenated, but 'a''b' is a
188+
// 3-char string with a single-quote in the middle.
189+
`SELECT 'a' 'b'`,
190+
() => sql`SELECT ${'a'}${'b'}`);
191+
},
192+
'merge-bq-bq': function () {
193+
runTagTest(
194+
'SELECT `a` `b`',
195+
() => sql`SELECT ${SqlString.identifier('a')}${SqlString.identifier('b')}`);
196+
},
197+
'merge-static-string-string': function () {
198+
runTagTest(
199+
`SELECT 'a' 'b'`,
200+
() => sql`SELECT 'a'${'b'}`);
201+
},
202+
'merge-string-static-string': function () {
203+
runTagTest(
204+
`SELECT 'a' 'b'`,
205+
() => sql`SELECT ${'a'}'b'`);
206+
},
207+
'not-a-merge-hazard': function () {
208+
runTagTest(
209+
`SELECT 'a''b'`,
210+
() => sql`SELECT 'a''b'`);
211+
}
212+
});

‎test/unit/es6/canary.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// A minimal file that uses ES6 features which will fail to load on
2+
// older browsers.
3+
`I load on ES6`;
4+
5+
module.exports = function usesRestArgs (...args) {
6+
return args;
7+
};

‎test/unit/test-Lexer.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
var canRequireES6 = true;
2+
try {
3+
require('./es6/canary');
4+
} catch (ignored) {
5+
canRequireES6 = false;
6+
}
7+
8+
if (canRequireES6) {
9+
require('./es6/Lexer');
10+
} else {
11+
console.info('Skipping ES6 tests for node_version %s', process.version);
12+
}

‎test/unit/test-SqlString.js

+68
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ test('SqlString.escapeId', {
4545

4646
'nested arrays are flattened': function() {
4747
assert.equal(SqlString.escapeId(['a', ['b', ['t.c']]]), '`a`, `b`, `t`.`c`');
48+
},
49+
50+
'rejects qualified id': function() {
51+
assert.throws(function() {
52+
SqlString.escapeId(SqlString.identifier('id1.id2', false), true);
53+
});
4854
}
4955
});
5056

@@ -262,6 +268,21 @@ test('SqlString.format', {
262268
assert.equal(sql, "'foo' or ??? and 'bar'");
263269
},
264270

271+
'double quest marks passes pre-escaped id': function () {
272+
var sql = SqlString.format(
273+
'SELECT ?? FROM ?? WHERE id = ?',
274+
[SqlString.identifier('table.id'), SqlString.identifier('table'), 42]);
275+
assert.equal(sql, 'SELECT `table`.`id` FROM `table` WHERE id = 42');
276+
},
277+
278+
'double quest marks rejects invalid raw': function () {
279+
assert.throws(function () {
280+
SqlString.format(
281+
'SELECT * FROM ?? WHERE id = 42',
282+
[SqlString.raw('NOW()')]);
283+
});
284+
},
285+
265286
'extra question marks are left untouched': function() {
266287
var sql = SqlString.format('? and ?', ['a']);
267288
assert.equal(sql, "'a' and ?");
@@ -334,3 +355,50 @@ test('SqlString.raw', {
334355
assert.equal(SqlString.raw("NOW() AS 'current_time'").toSqlString(), "NOW() AS 'current_time'");
335356
}
336357
});
358+
359+
test('SqlString.identifier', {
360+
'creates object': function() {
361+
assert.equal(typeof SqlString.identifier('i'), 'object');
362+
},
363+
364+
'rejects number': function() {
365+
assert.throws(function () {
366+
SqlString.identifier(42);
367+
});
368+
},
369+
370+
'rejects undefined': function() {
371+
assert.throws(function () {
372+
SqlString.identifier();
373+
});
374+
},
375+
376+
'object has toSqlString': function() {
377+
assert.equal(
378+
typeof SqlString.identifier('NOW()').toSqlString,
379+
'function');
380+
},
381+
382+
'toSqlString returns escaped id': function() {
383+
assert.equal(
384+
SqlString.identifier('Hello, World!').toSqlString(),
385+
'`Hello, World!`');
386+
},
387+
388+
'backticks escaped': function() {
389+
assert.equal(SqlString.identifier('I`m').toSqlString(), '`I``m`');
390+
},
391+
392+
'escape() does not re-escape': function() {
393+
assert.equal(SqlString.escape(SqlString.identifier('I`m')), '`I``m`');
394+
},
395+
396+
'qualified': function() {
397+
assert.equal(SqlString.identifier('id1.id2').toSqlString(), '`id1`.`id2`');
398+
assert.equal(SqlString.identifier('id1.id2', false).toSqlString(), '`id1`.`id2`');
399+
},
400+
401+
'rejects forbidQualified': function() {
402+
assert.equal(SqlString.identifier('id1.id2', true).toSqlString(), '`id1.id2`');
403+
}
404+
});

‎test/unit/test-Template.js

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
var assert = require('assert');
2+
var SqlString = require('../../');
3+
var test = require('utest');
4+
5+
var canRequireES6 = true;
6+
try {
7+
require('./es6/canary');
8+
} catch (ignored) {
9+
canRequireES6 = false;
10+
}
11+
12+
if (canRequireES6) {
13+
require('./es6/Template');
14+
} else {
15+
console.info('Skipping ES6 tests for node_version %s', process.version);
16+
17+
test('Template fallback', {
18+
'fallback sql': function () {
19+
var strings = ['SELECT ', ''];
20+
strings.raw = ['SELECT ', ''];
21+
assert.throws(
22+
function () {
23+
SqlString.sql(strings, 42);
24+
});
25+
}
26+
});
27+
}
28+
29+
var sql = SqlString.sql;
30+
31+
// Regardless of whether ES6 is availale, sql.calledAsTemplateTagQuick
32+
// should return false for non-tag calling conventions.
33+
test('sql.calledAsTemplateTagQuick', {
34+
'zero arguments': function () {
35+
assert.equal(sql.calledAsTemplateTagQuick(undefined, 0), false);
36+
},
37+
'some arguments': function () {
38+
assert.equal(sql.calledAsTemplateTagQuick(1, 2), false);
39+
},
40+
'string array first': function () {
41+
assert.equal(sql.calledAsTemplateTagQuick([''], 2), false);
42+
}
43+
});

0 commit comments

Comments
 (0)
Please sign in to comment.