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:
+
+[](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 @@
+
+
+
+
+
+
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);
+ }
+});