Skip to content

Contextual string tags to prevent SQL injection #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 88 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
855 changes: 855 additions & 0 deletions docs/sql-railroad.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
module.exports = require('./lib/SqlString');
module.exports.sql = require('./lib/Template');
30 changes: 29 additions & 1 deletion lib/SqlString.js
Original file line number Diff line number Diff line change
@@ -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 = '';
32 changes: 32 additions & 0 deletions lib/Template.js
Original file line number Diff line number Diff line change
@@ -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;
};
}
3 changes: 3 additions & 0 deletions lib/es6/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"parserOptions": { "ecmaVersion": 6 }
}
109 changes: 109 additions & 0 deletions lib/es6/Lexer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// A simple lexer for SQL.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dougwilson This separate file is now imported by the unittest instead getting rid of the conditional export.

// 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 + ')|/(?![*]))'
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dougwilson, The addition of WS fixes the failure to lex X--1 example you found.

) +
')*');
const DELIMITED_BODIES = {
'\'' : /^(?:[^'\\]|\\[\s\S]|'')*/,
'"' : /^(?:[^"\\]|\\[\s\S]|"")*/,
'`' : /^(?:[^`\\]|\\[\s\S]|``)*/
};

/**
* Template tag that creates a new Error with a message.
* @param {!Array.<string>} 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;
};
}
4 changes: 4 additions & 0 deletions lib/es6/README.md
Original file line number Diff line number Diff line change
@@ -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.
123 changes: 123 additions & 0 deletions lib/es6/Template.js
Original file line number Diff line number Diff line change
@@ -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.<string>} strings a valid TemplateObject.
* @return {!Array.<string>} 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.<string>} strings a valid TemplateObject.
* @return { !{
* delimiters : !Array.<string>,
* chunks: !Array.<string>
* } }
* 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');
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no good way to use an X"..." style syntax the way format/escape do.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this insert the data into the database without silent data loss for all possible values? If it's not possible to do the right escaping, we probably don't want to end up formatting silently for data loss, right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. https://dev.mysql.com/doc/refman/5.7/en/string-literals.html seems relevant

[_charset_name]'string' [COLLATE collation_name]

so it seems to depend on whether collations can be lossy even if charset decoding doesn't substitute U+FFFD or the like.

https://dev.mysql.com/doc/refman/5.7/en/adding-collation-simple-8bit.html seems to suggest they can be. The custom <collation name="latin1_test_ci"> at that link seems to downgrade to LATIN. Default collations can be associated with columns, so there may be no cues in the text.

}
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is an in memory cache, let's make sure to note in the README the maximum amount of RAM this is set to so folks will understand how much memory this will consume over the lifetime of their application.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Underlying the cache is a WeakMap, so it should adapt to memory pressure.

The maximum memory usage should scale with the number of uses of the tag that appear in the source code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand, sorry. Doesn't memoizing something cache some aspect in memory for later use? What is the maximum number of objects that will end up in that cache?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The maximum number of objects is 3 per occurrence of a relevant tag usage:

  • Two created arrays
  • One record that bundles the two together.

They're not pinned in memory since they key of the template object which is itself weakly referenced by the containing realm.

If that answers your question, I can put that in the docs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm still confused on how this is working and what it's doing under the hood. Is there another way you can explain what this is doing? For example, if you remove the memoization what does that do? What does this memoization provide? Is it calculating some things and storing in memory for later use? If so, for how long is that thing stored in memory? I'm trying to figure this out, because Node.js apps will typically stay running for months at a time if possible, and I'm concerned if this is going to introduce some kind of small long-term memory growth from memoization of many queries over the lifetime of the app. So maybe that would help provide context for the information I'm trying to understand is :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When sql`foo${bar}` is parsed by the EcmaScript engine, it creates a frozen array ['foo', ''].

When control reaches that expression, the memoizer uses that array as a key in a WeakMap lookup to decide whether it has to run the lexer or whether it can reuse.

The frozen array is itself weakly held by the module scope. This happens in "12.2.9.3Runtime Semantics: GetTemplateObject ( templateLiteral )". Although the spec doesn't make this explicit, it has to be weakly held because otherwise

function foo() {}
while (1) {
  eval('  foo`...`  ')
}

would leak memory.

So after a full garbage collection, any memoized state for scopes that are not reachable by any re-enterable scope will be flushed.

So the max size of the memo-table post full garbage collection should be the count of sql`...` calls in loaded modules.

By "re-enterable scope" above, I mean that those used only in (function () { ... }()) module initializers and in eval-ed code should not contribute in the steady state.

The min size of the memo-table will be the count of those uses that have been evaluated.

The memory cost per entry in the memo table will be the 3 objects detailed earlier.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bit of digging around and found tc39/ecma262#840 so some of my assumptions were misfounded. I'm going to replace the cache with an LRU cache which should put a constant limit on the memory overhead.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I replaced the WeakMap with an LRU cache.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the LRU cache size in bytes set to / is it adjustable?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache caps at 100 entries.

I'm going to assume that in the 3σ case, all uses fits on a 120 column line and have 12 ${...}s.
In this 3σ case, a full cache consumes 11.7kB.

I'm going to assume that the average case has 3 or fewer ${...}.
In the typical case, a full cache consumes 4.7kB.

If storing a single ASCII character in an array costs less than an 8B pointer, then it will be correspondingly cheaper.

I could probably save some space by joining the contexts into a string instead of storing characters in an array, but I don't have the tooling to do memory micro-benchmarks so can't say for sure.

The LRU cache size is not adjustable. I could make that adjustable if you have an idea for an API.

sql.calledAsTemplateTagQuick = calledAsTemplateTagQuick;

module.exports = sql;
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
12 changes: 12 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
@@ -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
```
3 changes: 3 additions & 0 deletions test/unit/es6/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"parserOptions": { "ecmaVersion": 6 }
}
79 changes: 79 additions & 0 deletions test/unit/es6/Lexer.js
Original file line number Diff line number Diff line change
@@ -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 "\\\\"$/);
}
});
212 changes: 212 additions & 0 deletions test/unit/es6/Template.js
Original file line number Diff line number Diff line change
@@ -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'`);
}
});
7 changes: 7 additions & 0 deletions test/unit/es6/canary.js
Original file line number Diff line number Diff line change
@@ -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;
};
12 changes: 12 additions & 0 deletions test/unit/test-Lexer.js
Original file line number Diff line number Diff line change
@@ -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);
}
68 changes: 68 additions & 0 deletions test/unit/test-SqlString.js
Original file line number Diff line number Diff line change
@@ -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`');
}
});
43 changes: 43 additions & 0 deletions test/unit/test-Template.js
Original file line number Diff line number Diff line change
@@ -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);
}
});