Skip to content

Commit 77686b7

Browse files
committed
Fix isses raised by dougwilson.
* Split Lexer.js out of Template.js so that tests can fetch that directly instead of conditionally exporting the lexer. * Also split the tests for Lexer.js out of the tests for Template.js * Template no longer does its own Array escaping. All escaping is delegated to either SqlString.escape or SqlString.escapeId. * Now provides SqlString.identifier which produces a { toSqlString: ... } value similar to that produced by SqlString.raw. * Changed SqlString.escapeId to pass through values like those produced by SqlString.identifier. Still TODO: update documentation. * Template.Identifier and Template.SqlFragment use `instanceof` in dodgy ways and are redundant. * null/undefined do not interpolate well * Buffers do not interpolate properly inside quoted sections. Now uses Buffer.toString('binary') * Lexer recognizes in one place that -- comments need to have a following space per dev.mysql.com/doc/refman/5.7/en/ansi-diff-comments.html but a later does not handle that caveat. * There was no way to pass (stringifyObjects, timeZone) options to interpolations. Changes enable sql({ timeZone })`...`
1 parent 7e93087 commit 77686b7

File tree

10 files changed

+307
-254
lines changed

10 files changed

+307
-254
lines changed

lib/SqlString.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ var CHARS_ESCAPE_MAP = {
1414
'\'' : '\\\'',
1515
'\\' : '\\\\'
1616
};
17+
var ONE_ID_PATTERN = '^`(?:[^`]|``)+`$' // TODO(mikesamuel): Should allow ``?
18+
// One or more Identifiers separated by dots.
19+
var QUALIFIED_ID_REGEXP = new RegExp(
20+
'^' + ONE_ID_PATTERN + '(?:[.]' + ONE_ID_PATTERN + ')*$');
1721

1822
SqlString.escapeId = function escapeId(val, forbidQualified) {
1923
if (Array.isArray(val)) {
@@ -24,6 +28,14 @@ SqlString.escapeId = function escapeId(val, forbidQualified) {
2428
}
2529

2630
return sql;
31+
} else if (val && typeof val.toSqlString === 'function') {
32+
// If it corresponds to an identifier token, let it through.
33+
var sqlString = val.toSqlString();
34+
if (QUALIFIED_ID_REGEXP.test(sqlString)) {
35+
return sqlString;
36+
} else {
37+
throw new TypeError();
38+
}
2739
} else if (forbidQualified) {
2840
return '`' + String(val).replace(ID_GLOBAL_REGEXP, '``') + '`';
2941
} else {
@@ -140,7 +152,7 @@ SqlString.dateToString = function dateToString(date, timeZone) {
140152
dt.setTime(dt.getTime() + (tz * 60000));
141153
}
142154

143-
year = dt.getUTCFullYear();
155+
year = dt.getUTCFullYear();
144156
month = dt.getUTCMonth() + 1;
145157
day = dt.getUTCDate();
146158
hour = dt.getUTCHours();
@@ -187,6 +199,17 @@ SqlString.raw = function raw(sql) {
187199
};
188200
};
189201

202+
SqlString.identifier = function identifier(id, forbidQualified) {
203+
if (typeof id !== 'string') {
204+
throw new TypeError('argument id must be a string');
205+
}
206+
207+
var idToken = SqlString.escapeId(id, forbidQualified);
208+
return {
209+
toSqlString: function toSqlString() { return idToken; }
210+
};
211+
};
212+
190213
function escapeString(val) {
191214
var chunkIndex = CHARS_GLOBAL_REGEXP.lastIndex = 0;
192215
var escapedVal = '';

lib/Template.js

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,28 +27,4 @@ try {
2727
module.exports.calledAsTemplateTagQuick = function (firstArg, nArgs) {
2828
return false;
2929
};
30-
31-
function stringWrapper() {
32-
function TypedString(content) {
33-
if (!(this instanceof TypedString)) {
34-
return new TypedString(content);
35-
}
36-
this.content = String(content);
37-
}
38-
TypedString.prototype.toString = function () {
39-
return this.content;
40-
};
41-
return TypedString;
42-
}
43-
44-
/**
45-
* @param {string} content
46-
* @constructor
47-
*/
48-
module.exports.Identifier = stringWrapper();
49-
/**
50-
* @param {string} content
51-
* @constructor
52-
*/
53-
module.exports.Fragment = stringWrapper();
5430
}

lib/es6/Lexer.js

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

lib/es6/README.md

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

0 commit comments

Comments
 (0)