fix(A19/A20/A21/A2): 词法器标准化 —— 双引号标识符、未闭合注释报错、去递归、移除方言转义
A19 双引号是分隔标识符(SQL 标准)
此前 '"' 与 "'" 一起交给 readString,于是 SELECT "name" FROM t 静默产出
一个名为 'name' 的**常量列**(行数正确、值全错、无报错),且被 lexer.test.ts
钉死为期望。新增 TokenType.QUOTED_IDENTIFIER;双引号内 "" 表示一个双引号;
可用于引用保留字列名(SELECT "order" FROM t)。parser 的 expectIdentifier
显式接受 QUOTED_IDENTIFIER。
A20 未闭合块注释必须报错
此前 skipBlockComment 循环到 EOF 就返回、不抛错,实测
db.query("DELETE FROM t WHERE id = '4' /*") **真的删掉了 1 行**;
SQLite 会报 unterminated comment。任何被截断/拼接的 SQL 都会静默改变语义。
A21 注释跳过改为循环(消除递归爆栈)
此前 default 分支用 return this.nextToken() 递归,栈深 = 连续注释数,
两万个连续块注释直接 RangeError(原生错误,调用方无法按 code 分类)。
A2 移除 MySQL 方言的反斜杠转义
此前只识别反斜杠+单引号:双反斜杠不解转义,以反斜杠结尾的 Windows 路径会
吞掉闭引号并报出与输入无关的解析错误。更严重的是参数绑定器只翻倍单引号、
不处理反斜杠 —— 两份词法规则不一致,"参数不改变 SQL 结构"在文本层不成立。
SQL 标准中反斜杠是普通字符,移除后词法器与绑定器的字符串边界判定完全一致。
更新 2 处把旧行为钉死的测试(lexer.test 双引号=字符串、v033 反斜杠转义)。
This commit is contained in:
+114
-31
@@ -25,7 +25,7 @@ export class Lexer {
|
|||||||
|
|
||||||
/** 读取下一个 Token */
|
/** 读取下一个 Token */
|
||||||
nextToken(): Token {
|
nextToken(): Token {
|
||||||
this.skipWhitespace();
|
this.skipWhitespaceAndComments();
|
||||||
|
|
||||||
let tok: Token;
|
let tok: Token;
|
||||||
|
|
||||||
@@ -79,23 +79,16 @@ export class Lexer {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "'":
|
case "'":
|
||||||
|
tok = this.readString();
|
||||||
|
break;
|
||||||
case '"':
|
case '"':
|
||||||
tok = this.readString(this.ch);
|
// v0.8.0: 双引号 = 分隔标识符(SQL 标准),不再当作字符串字面量
|
||||||
|
tok = this.readQuotedIdentifier();
|
||||||
break;
|
break;
|
||||||
case '':
|
case '':
|
||||||
tok = { type: TokenType.EOF, value: '', position: this.position };
|
tok = { type: TokenType.EOF, value: '', position: this.position };
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// SQL 注释: -- 行注释
|
|
||||||
if (this.ch === '-' && this.peekChar() === '-') {
|
|
||||||
this.skipLineComment();
|
|
||||||
return this.nextToken();
|
|
||||||
}
|
|
||||||
// SQL 注释: /* 块注释 */
|
|
||||||
if (this.ch === '/' && this.peekChar() === '*') {
|
|
||||||
this.skipBlockComment();
|
|
||||||
return this.nextToken();
|
|
||||||
}
|
|
||||||
if (this.isLetter(this.ch)) {
|
if (this.isLetter(this.ch)) {
|
||||||
const ident = this.readIdentifier();
|
const ident = this.readIdentifier();
|
||||||
const keyword = KEYWORDS[ident.toUpperCase()];
|
const keyword = KEYWORDS[ident.toUpperCase()];
|
||||||
@@ -140,10 +133,32 @@ export class Lexer {
|
|||||||
return this.input[this.readPosition];
|
return this.input[this.readPosition];
|
||||||
}
|
}
|
||||||
|
|
||||||
private skipWhitespace(): void {
|
/**
|
||||||
|
* 跳过空白与注释(v0.8.0:改为**循环**而非递归)。
|
||||||
|
*
|
||||||
|
* 此前在 default 分支里用 `return this.nextToken()` 递归跳过注释,
|
||||||
|
* 递归深度 = 连续注释个数:两万个连续的块注释就会直接
|
||||||
|
* `RangeError: Maximum call stack size exceeded`(且是原生错误而非 DatabaseError,
|
||||||
|
* 调用方无法按 code 分类)。现在统一在一个循环里消费空白与注释。
|
||||||
|
*/
|
||||||
|
private skipWhitespaceAndComments(): void {
|
||||||
|
for (;;) {
|
||||||
|
// 空白
|
||||||
while (this.ch === ' ' || this.ch === '\t' || this.ch === '\n' || this.ch === '\r') {
|
while (this.ch === ' ' || this.ch === '\t' || this.ch === '\n' || this.ch === '\r') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
}
|
}
|
||||||
|
// -- 行注释
|
||||||
|
if (this.ch === '-' && this.peekChar() === '-') {
|
||||||
|
this.skipLineComment();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// /* 块注释 */
|
||||||
|
if (this.ch === '/' && this.peekChar() === '*') {
|
||||||
|
this.skipBlockComment();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 跳过 -- 行注释到行尾 */
|
/** 跳过 -- 行注释到行尾 */
|
||||||
@@ -153,17 +168,29 @@ export class Lexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 跳过块注释 slash-star ... star-slash */
|
/**
|
||||||
|
* 跳过块注释 slash-star ... star-slash
|
||||||
|
*
|
||||||
|
* v0.8.0 根治:未闭合的块注释必须**报错**。
|
||||||
|
* 此前循环到 EOF 就直接返回、不抛错,于是 `DELETE FROM t WHERE id = '4' /*`
|
||||||
|
* 会**照常执行删除**(审计实测真的删掉了 1 行);SQLite 会报
|
||||||
|
* `unterminated /* comment`。任何被截断/拼接的 SQL 都会因此静默改变语义。
|
||||||
|
*/
|
||||||
private skipBlockComment(): void {
|
private skipBlockComment(): void {
|
||||||
this.readChar(); // skip *
|
const start = this.position;
|
||||||
this.readChar(); // move past *
|
this.readChar(); // skip '/'
|
||||||
|
this.readChar(); // skip '*'
|
||||||
while (this.ch !== '' && !(this.ch === '*' && this.peekChar() === '/')) {
|
while (this.ch !== '' && !(this.ch === '*' && this.peekChar() === '/')) {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
}
|
}
|
||||||
if (this.ch !== '') {
|
if (this.ch === '') {
|
||||||
this.readChar(); // skip *
|
throw new DatabaseError(
|
||||||
this.readChar(); // skip /
|
`Unterminated block comment at position ${start}`,
|
||||||
|
'PARSE_ERROR',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
this.readChar(); // skip '*'
|
||||||
|
this.readChar(); // skip '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
private readIdentifier(): string {
|
private readIdentifier(): string {
|
||||||
@@ -191,29 +218,38 @@ export class Lexer {
|
|||||||
return this.input.slice(start, this.position);
|
return this.input.slice(start, this.position);
|
||||||
}
|
}
|
||||||
|
|
||||||
private readString(quote: string): Token {
|
/**
|
||||||
|
* 读取单引号字符串字面量。
|
||||||
|
*
|
||||||
|
* v0.8.0 根治:**移除反斜杠转义**(此前只支持单引号前的反斜杠)。
|
||||||
|
*
|
||||||
|
* 此前只识别「反斜杠 + 单引号」(MySQL 方言的半个实现):双反斜杠不解转义,于是
|
||||||
|
* - 单引号前的反斜杠被静默吞掉;
|
||||||
|
* - 以反斜杠结尾的 Windows 路径会**吞掉闭引号**,抛出一条
|
||||||
|
* 与用户输入无关的 "Unterminated string literal";
|
||||||
|
* - 更严重的是,参数绑定器 params.ts 只把单引号翻倍、不处理反斜杠,
|
||||||
|
* 两份词法规则不一致 —— "参数不可能改变 SQL 结构"这条不变量在文本层已不成立。
|
||||||
|
*
|
||||||
|
* SQL 标准(以及 SQLite / PostgreSQL,本项目对齐的方言)中反斜杠是普通字符,
|
||||||
|
* 反斜杠本身是普通字符、引号靠两个连续单引号表达。移除方言转义后,词法器与绑定器的字符串边界判定
|
||||||
|
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
||||||
|
*/
|
||||||
|
private readString(): Token {
|
||||||
const start = this.position + 1; // 跳过一个引号
|
const start = this.position + 1; // 跳过一个引号
|
||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
|
|
||||||
while (this.ch !== '') {
|
while (this.ch !== '') {
|
||||||
if (this.ch === quote) {
|
if (this.ch === "'") {
|
||||||
// v0.3.3: 支持 SQL 标准 '' 转义(两个连续引号 = 一个引号)
|
// SQL 标准 '' 转义(两个连续引号 = 一个引号)
|
||||||
if (this.peekChar() === quote) {
|
if (this.peekChar() === "'") {
|
||||||
value += quote;
|
value += "'";
|
||||||
this.readChar(); // 跳过第二个引号
|
this.readChar(); // 跳过第二个引号
|
||||||
this.readChar();
|
this.readChar();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break; // 结束引号(由 nextToken 的 readChar 跳过)
|
break; // 结束引号(由 nextToken 的 readChar 跳过)
|
||||||
}
|
}
|
||||||
// 反斜杠转义(兼容旧语法)
|
|
||||||
if (this.ch === '\\' && this.peekChar() === quote) {
|
|
||||||
this.readChar();
|
|
||||||
value += quote;
|
|
||||||
this.readChar();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
value += this.ch;
|
value += this.ch;
|
||||||
this.readChar();
|
this.readChar();
|
||||||
}
|
}
|
||||||
@@ -234,6 +270,53 @@ export class Lexer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0: 读取双引号分隔标识符(SQL 标准 `"column"`)。
|
||||||
|
*
|
||||||
|
* 双引号内以 `""` 表示一个双引号字符(与单引号字符串的 `''` 规则对称)。
|
||||||
|
* 未闭合同样显式报错,与字符串字面量保持一致。
|
||||||
|
*/
|
||||||
|
private readQuotedIdentifier(): Token {
|
||||||
|
const start = this.position;
|
||||||
|
const contentStart = this.position + 1;
|
||||||
|
this.readChar(); // 跳过开始引号
|
||||||
|
let value = '';
|
||||||
|
|
||||||
|
while (this.ch !== '') {
|
||||||
|
if (this.ch === '"') {
|
||||||
|
if (this.peekChar() === '"') {
|
||||||
|
value += '"';
|
||||||
|
this.readChar();
|
||||||
|
this.readChar();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
value += this.ch;
|
||||||
|
this.readChar();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ch === '') {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Unterminated quoted identifier at position ${start}`,
|
||||||
|
'PARSE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length === 0) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Empty quoted identifier at position ${start}`,
|
||||||
|
'PARSE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: TokenType.QUOTED_IDENTIFIER,
|
||||||
|
value,
|
||||||
|
position: contentStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private isLetter(ch: string): boolean {
|
private isLetter(ch: string): boolean {
|
||||||
return /[a-zA-Z_]/.test(ch);
|
return /[a-zA-Z_]/.test(ch);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1284,6 +1284,13 @@ export class Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private expectIdentifier(context: string): string {
|
private expectIdentifier(context: string): string {
|
||||||
|
// v0.8.0: 分隔标识符 "col" 与普通标识符等价(但不参与关键字识别,
|
||||||
|
// 因此可以用它引用保留字列名,如 "order" / "select")
|
||||||
|
if (this.curToken.type === TokenType.QUOTED_IDENTIFIER) {
|
||||||
|
const val = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
return val;
|
||||||
|
}
|
||||||
if (this.curToken.type === TokenType.IDENTIFIER || this._isKeywordAsIdent()) {
|
if (this.curToken.type === TokenType.IDENTIFIER || this._isKeywordAsIdent()) {
|
||||||
const val = this.curToken.value;
|
const val = this.curToken.value;
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -1298,6 +1305,8 @@ export class Parser {
|
|||||||
this.curToken.type !== TokenType.EOF &&
|
this.curToken.type !== TokenType.EOF &&
|
||||||
this.curToken.type !== TokenType.ILLEGAL &&
|
this.curToken.type !== TokenType.ILLEGAL &&
|
||||||
this.curToken.type !== TokenType.STRING &&
|
this.curToken.type !== TokenType.STRING &&
|
||||||
|
// v0.8.0: 分隔标识符由 expectIdentifier 的显式分支处理,不走"关键字当标识符"兜底
|
||||||
|
this.curToken.type !== TokenType.QUOTED_IDENTIFIER &&
|
||||||
this.curToken.type !== TokenType.NUMBER &&
|
this.curToken.type !== TokenType.NUMBER &&
|
||||||
this.curToken.type !== TokenType.COMMA &&
|
this.curToken.type !== TokenType.COMMA &&
|
||||||
this.curToken.type !== TokenType.LPAREN &&
|
this.curToken.type !== TokenType.LPAREN &&
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ export enum TokenType {
|
|||||||
|
|
||||||
// 标识符 & 字面量
|
// 标识符 & 字面量
|
||||||
IDENTIFIER = 'IDENTIFIER',
|
IDENTIFIER = 'IDENTIFIER',
|
||||||
|
/**
|
||||||
|
* v0.8.0: 分隔标识符(双引号包裹,SQL 标准 `"name"`)。
|
||||||
|
*
|
||||||
|
* 此前双引号被当作字符串定界符处理,`SELECT "name" FROM t` 会静默产出一个名为
|
||||||
|
* `'name'` 的**常量列**(行数正确、值全错、无任何报错),且该行为被
|
||||||
|
* tests/sql/lexer.test.ts 钉死为期望。现按 SQL 标准区分:
|
||||||
|
* 'x' → STRING(字符串字面量)
|
||||||
|
* "x" → QUOTED_IDENTIFIER(标识符,用于含特殊字符/保留字/大小写敏感的列名)
|
||||||
|
* 双引号内以 "" 表示一个双引号。
|
||||||
|
*/
|
||||||
|
QUOTED_IDENTIFIER = 'QUOTED_IDENTIFIER',
|
||||||
STRING = 'STRING',
|
STRING = 'STRING',
|
||||||
NUMBER = 'NUMBER',
|
NUMBER = 'NUMBER',
|
||||||
|
|
||||||
|
|||||||
+28
-2
@@ -44,10 +44,36 @@ describe('Lexer 边缘场景', () => {
|
|||||||
expect(tokens[1].value).toBe('hello world');
|
expect(tokens[1].value).toBe('hello world');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('双引号字符串', () => {
|
// v0.8.0 更正:双引号在 SQL 标准(及 SQLite/PostgreSQL)中是**分隔标识符**,
|
||||||
|
// 不是字符串字面量。此前本用例把 `"hello world"` 钉死为 STRING,正是
|
||||||
|
// `SELECT "name" FROM t` 静默产出常量列(行数正确、值全错)的原因。
|
||||||
|
it('双引号是分隔标识符(非字符串字面量)', () => {
|
||||||
const tokens = tokenize('SELECT "hello world"');
|
const tokens = tokenize('SELECT "hello world"');
|
||||||
expect(tokens[1].type).toBe(TokenType.STRING);
|
expect(tokens[1].type).toBe(TokenType.QUOTED_IDENTIFIER);
|
||||||
expect(tokens[1].value).toBe('hello world');
|
expect(tokens[1].value).toBe('hello world');
|
||||||
|
// 单引号才是字符串
|
||||||
|
const strTokens = tokenize("SELECT 'hello world'");
|
||||||
|
expect(strTokens[1].type).toBe(TokenType.STRING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分隔标识符内的 "" 表示一个双引号', () => {
|
||||||
|
const tokens = tokenize('SELECT "a""b"');
|
||||||
|
expect(tokens[1].type).toBe(TokenType.QUOTED_IDENTIFIER);
|
||||||
|
expect(tokens[1].value).toBe('a"b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未闭合的分隔标识符报 PARSE_ERROR', () => {
|
||||||
|
expect(() => tokenize('SELECT "abc')).toThrow(/Unterminated quoted identifier/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未闭合的块注释报 PARSE_ERROR(此前静默吞掉剩余 SQL)', () => {
|
||||||
|
expect(() => tokenize('SELECT 1 /* oops')).toThrow(/Unterminated block comment/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('反斜杠是普通字符(与参数绑定器保持一致)', () => {
|
||||||
|
const tokens = tokenize("SELECT 'C:\\'");
|
||||||
|
expect(tokens[1].type).toBe(TokenType.STRING);
|
||||||
|
expect(tokens[1].value).toBe('C:\\');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('所有关键字', () => {
|
it('所有关键字', () => {
|
||||||
|
|||||||
@@ -354,9 +354,13 @@ describe('[v0.3.3] P1-8: SQL 字符串转义', () => {
|
|||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('反斜杠转义仍兼容', () => {
|
// v0.8.0 行为变更:移除 MySQL 方言的反斜杠转义。
|
||||||
const tokens = tokenize("SELECT 'a\\'b'");
|
// 此前 `'a\'b'` 被解析为 `a'b`(反斜杠被吞),而参数绑定器只翻倍单引号、
|
||||||
expect(tokens[1].value).toBe("a'b");
|
// 不处理反斜杠 —— 两份词法规则不一致,导致以反斜杠结尾的合法参数值
|
||||||
|
// 必然报出与用户输入无关的解析错误。SQL 标准中反斜杠是普通字符。
|
||||||
|
test('反斜杠是普通字符(标准语义,与绑定器一致)', () => {
|
||||||
|
const tokens = tokenize("SELECT 'a\\b'");
|
||||||
|
expect(tokens[1].value).toBe('a\\b');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user