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'); }); });