From 7526951804f071c66e1d2a046906dc8c08b384e0 Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 14 Sep 2026 21:08:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(A19/A20/A21/A2):=20=E8=AF=8D=E6=B3=95?= =?UTF-8?q?=E5=99=A8=E6=A0=87=E5=87=86=E5=8C=96=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=8F=8C=E5=BC=95=E5=8F=B7=E6=A0=87=E8=AF=86=E7=AC=A6=E3=80=81?= =?UTF-8?q?=E6=9C=AA=E9=97=AD=E5=90=88=E6=B3=A8=E9=87=8A=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E3=80=81=E5=8E=BB=E9=80=92=E5=BD=92=E3=80=81=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=96=B9=E8=A8=80=E8=BD=AC=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 反斜杠转义)。 --- src/sql/lexer.ts | 149 ++++++++++++++++++++++++++++++--------- src/sql/parser.ts | 9 +++ src/sql/tokens.ts | 11 +++ tests/sql/lexer.test.ts | 30 +++++++- tests/v033-fixes.test.ts | 10 ++- 5 files changed, 171 insertions(+), 38 deletions(-) diff --git a/src/sql/lexer.ts b/src/sql/lexer.ts index 88083fd..f5d42ba 100644 --- a/src/sql/lexer.ts +++ b/src/sql/lexer.ts @@ -25,7 +25,7 @@ export class Lexer { /** 读取下一个 Token */ nextToken(): Token { - this.skipWhitespace(); + this.skipWhitespaceAndComments(); let tok: Token; @@ -79,23 +79,16 @@ export class Lexer { } break; case "'": + tok = this.readString(); + break; case '"': - tok = this.readString(this.ch); + // v0.8.0: 双引号 = 分隔标识符(SQL 标准),不再当作字符串字面量 + tok = this.readQuotedIdentifier(); break; case '': tok = { type: TokenType.EOF, value: '', position: this.position }; break; 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)) { const ident = this.readIdentifier(); const keyword = KEYWORDS[ident.toUpperCase()]; @@ -140,9 +133,31 @@ export class Lexer { return this.input[this.readPosition]; } - private skipWhitespace(): void { - while (this.ch === ' ' || this.ch === '\t' || this.ch === '\n' || this.ch === '\r') { - this.readChar(); + /** + * 跳过空白与注释(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') { + 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 { - this.readChar(); // skip * - this.readChar(); // move past * + const start = this.position; + this.readChar(); // skip '/' + this.readChar(); // skip '*' while (this.ch !== '' && !(this.ch === '*' && this.peekChar() === '/')) { this.readChar(); } - if (this.ch !== '') { - this.readChar(); // skip * - this.readChar(); // skip / + if (this.ch === '') { + throw new DatabaseError( + `Unterminated block comment at position ${start}`, + 'PARSE_ERROR', + ); } + this.readChar(); // skip '*' + this.readChar(); // skip '/' } private readIdentifier(): string { @@ -191,29 +218,38 @@ export class Lexer { 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; // 跳过一个引号 this.readChar(); // 跳过开始引号 let value = ''; while (this.ch !== '') { - if (this.ch === quote) { - // v0.3.3: 支持 SQL 标准 '' 转义(两个连续引号 = 一个引号) - if (this.peekChar() === quote) { - value += quote; + if (this.ch === "'") { + // SQL 标准 '' 转义(两个连续引号 = 一个引号) + if (this.peekChar() === "'") { + value += "'"; this.readChar(); // 跳过第二个引号 this.readChar(); continue; } break; // 结束引号(由 nextToken 的 readChar 跳过) } - // 反斜杠转义(兼容旧语法) - if (this.ch === '\\' && this.peekChar() === quote) { - this.readChar(); - value += quote; - this.readChar(); - continue; - } value += this.ch; 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 { return /[a-zA-Z_]/.test(ch); } diff --git a/src/sql/parser.ts b/src/sql/parser.ts index 237430e..9c9fad2 100644 --- a/src/sql/parser.ts +++ b/src/sql/parser.ts @@ -1284,6 +1284,13 @@ export class Parser { } 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()) { const val = this.curToken.value; this.nextToken(); @@ -1298,6 +1305,8 @@ export class Parser { this.curToken.type !== TokenType.EOF && this.curToken.type !== TokenType.ILLEGAL && this.curToken.type !== TokenType.STRING && + // v0.8.0: 分隔标识符由 expectIdentifier 的显式分支处理,不走"关键字当标识符"兜底 + this.curToken.type !== TokenType.QUOTED_IDENTIFIER && this.curToken.type !== TokenType.NUMBER && this.curToken.type !== TokenType.COMMA && this.curToken.type !== TokenType.LPAREN && diff --git a/src/sql/tokens.ts b/src/sql/tokens.ts index 2c00e69..12de3f6 100644 --- a/src/sql/tokens.ts +++ b/src/sql/tokens.ts @@ -94,6 +94,17 @@ export enum TokenType { // 标识符 & 字面量 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', NUMBER = 'NUMBER', diff --git a/tests/sql/lexer.test.ts b/tests/sql/lexer.test.ts index 8e77ce3..ab081d7 100644 --- a/tests/sql/lexer.test.ts +++ b/tests/sql/lexer.test.ts @@ -44,10 +44,36 @@ describe('Lexer 边缘场景', () => { 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"'); - expect(tokens[1].type).toBe(TokenType.STRING); + expect(tokens[1].type).toBe(TokenType.QUOTED_IDENTIFIER); 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('所有关键字', () => { diff --git a/tests/v033-fixes.test.ts b/tests/v033-fixes.test.ts index b12c0b6..ca61833 100644 --- a/tests/v033-fixes.test.ts +++ b/tests/v033-fixes.test.ts @@ -354,9 +354,13 @@ describe('[v0.3.3] P1-8: SQL 字符串转义', () => { await db.close(); }); - test('反斜杠转义仍兼容', () => { - const tokens = tokenize("SELECT 'a\\'b'"); - expect(tokens[1].value).toBe("a'b"); + // v0.8.0 行为变更:移除 MySQL 方言的反斜杠转义。 + // 此前 `'a\'b'` 被解析为 `a'b`(反斜杠被吞),而参数绑定器只翻倍单引号、 + // 不处理反斜杠 —— 两份词法规则不一致,导致以反斜杠结尾的合法参数值 + // 必然报出与用户输入无关的解析错误。SQL 标准中反斜杠是普通字符。 + test('反斜杠是普通字符(标准语义,与绑定器一致)', () => { + const tokens = tokenize("SELECT 'a\\b'"); + expect(tokens[1].value).toBe('a\\b'); }); });