-
Notifications
You must be signed in to change notification settings - Fork 78
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
module.exports = require('./lib/SqlString'); | ||
module.exports.sql = require('./lib/Template'); |
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; | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"parserOptions": { "ecmaVersion": 6 } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 + ')|/(?![*]))' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @dougwilson, The addition of WS fixes the failure to lex |
||
) + | ||
')*'); | ||
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; | ||
}; | ||
} |
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. |
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'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I found no good way to use an There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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 |
||
} | ||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Underlying the cache is a The maximum memory usage should scale with the number of uses of the tag that appear in the source code. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When When control reaches that expression, the memoizer uses that array as a key in a 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 By "re-enterable scope" above, I mean that those used only in 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I replaced the WeakMap with an LRU cache. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I'm going to assume that the average case has 3 or fewer 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; |
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 | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"parserOptions": { "ecmaVersion": 6 } | ||
} |
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 "\\\\"$/); | ||
} | ||
}); |
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'`); | ||
} | ||
}); |
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; | ||
}; |
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); | ||
} |
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); | ||
} | ||
}); |
There was a problem hiding this comment.
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.