Skip to content

Commit

Permalink
Merge dfca006 into 8e91922
Browse files Browse the repository at this point in the history
  • Loading branch information
mikesamuel committed Feb 18, 2018
2 parents 8e91922 + dfca006 commit 97f3942
Show file tree
Hide file tree
Showing 16 changed files with 649 additions and 1 deletion.
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');
27 changes: 26 additions & 1 deletion lib/SqlString.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ var CHARS_ESCAPE_MAP = {
'\'' : '\\\'',
'\\' : '\\\\'
};
var ONE_ID_PATTERN = '`(?:[^`]|``)+`'; // TODO(mikesamuel): Should allow ``?
// One or more Identifiers separated by dots.
var QUALIFIED_ID_REGEXP = new RegExp(
'^' + ONE_ID_PATTERN + '(?:[.]' + ONE_ID_PATTERN + ')*$');

SqlString.escapeId = function escapeId(val, forbidQualified) {
if (Array.isArray(val)) {
Expand All @@ -24,6 +28,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 (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 {
Expand Down Expand Up @@ -140,7 +154,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();
Expand Down Expand Up @@ -187,6 +201,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 = '';
Expand Down
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.
// 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.<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.
102 changes: 102 additions & 0 deletions lib/es6/Template.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// 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');

/**
* Analyzes the static parts of the tag content.
*
* @param {!Array.<string>} strings a valid TemplateObject.
* @return { !{
* raw: !Array.<string>,
* delimiters : !Array.<string>,
* chunks: !Array.<string>
* } }
* A record like { raw, delimiters, chunks }
* where delimiter is a contextual cue and chunk is
* the adjusted raw text.
*/
function computeStatic (strings) {
const { raw } = trimCommonWhitespaceFromLines(strings);

const delimiters = [];
const chunks = [];

const lexer = makeLexer();

let delimiter = null;
for (let i = 0, len = raw.length; i < len; ++i) {
let chunk = String(raw[i]);
if (delimiter === '`') {
// Treat raw \` in an identifier literal as an ending delimiter.
chunk = chunk.replace(/^([^\\`]|\\[\s\S])*\\`/, '$1`');
}
const newDelimiter = lexer(chunk);
if (newDelimiter === '`' && !delimiter) {
// Treat literal \` outside a string context as starting an
// identifier literal
chunk = chunk.replace(
/((?:^|[^\\])(?:\\\\)*)\\(`(?:[^`\\]|\\[\s\S])*)$/, '$1$2');
}

chunks.push(chunk);
delimiters.push(newDelimiter);
delimiter = newDelimiter;
}

if (delimiter) {
throw new Error(`Unclosed quoted string: ${delimiter}`);
}

return { raw, delimiters, chunks };
}

function interpolateSqlIntoFragment (
{ stringifyObjects, timeZone },
{ raw, delimiters, chunks },
strings, values) {
// A buffer to accumulate output.
let [ result ] = chunks;
for (let i = 1, len = raw.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];

if (delimiter) {
result += escapeDelimitedValue(value, delimiter, timeZone);
} else {
result += SqlString.escape(value, stringifyObjects, timeZone);
}

result += chunk;
}

return SqlString.raw(result);
}

function escapeDelimitedValue (value, delimiter, timeZone) {
if (delimiter === '`') {
return SqlString.escapeId(String(value)).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);
}

/**
* Template tag function that contextually autoescapes values
* producing a SqlFragment.
*/
const sql = memoizedTagFunction(computeStatic, interpolateSqlIntoFragment);
sql.calledAsTemplateTagQuick = calledAsTemplateTagQuick;

module.exports = sql;
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"sql escape"
],
"repository": "mysqljs/sqlstring",
"dependencies": {
"template-tag-common": "3.0.2"
},
"devDependencies": {
"beautify-benchmark": "0.2.4",
"benchmark": "2.1.4",
Expand Down
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 }
}

0 comments on commit 97f3942

Please sign in to comment.