Skip to content

Commit af3abfc

Browse files
committed
chore: remove settings.local.json and update .gitignore; fix token type name for DOT; improve error recovery in parser; standardize NULL casing in test cases
1 parent d2b4826 commit af3abfc

9 files changed

Lines changed: 97 additions & 35 deletions

File tree

.claude/settings.local.json

Lines changed: 0 additions & 14 deletions
This file was deleted.

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,4 +321,8 @@ $RECYCLE.BIN/
321321

322322
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
323323

324-
docs-old
324+
docs-old
325+
326+
# Claude Code
327+
.claude/worktrees
328+
.claude/settings.local.json

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## 7.1.0
4+
5+
Mar 21, 2026
6+
7+
### Bug Fixes
8+
9+
- **Fixed error recovery dropping subsequent clauses** - When using `ignoreParseErrors: true`, if a clause failed to parse, all subsequent clauses (ORDER BY, LIMIT, OFFSET, etc.) were silently dropped. The parser now correctly synchronizes forward and continues parsing remaining clauses.
10+
- **Standardized `NULL` value casing in array expressions** - `NULL` values inside `IN` / `NOT IN` / `INCLUDES` / `EXCLUDES` arrays now use uppercase `'NULL'` in the parsed `value` field, consistent with scalar `NULL` comparisons. The composer still outputs lowercase `null` in SOQL strings. If you were comparing `condition.value` items with a case-sensitive `=== 'null'` check, update to `=== 'NULL'`.
11+
- **Removed incorrect `DOT` token name alias** - `tokenTypeName(TokenKind.DOT)` now returns `'DOT'` instead of the incorrect `'DECIMAL'`. This only affects consumers using the low-level `tokenTypeName` API.
12+
13+
### Internal
14+
15+
- Removed singleton parser pattern — `parseQuery` now creates a fresh parser instance per call, eliminating shared mutable state and making the parser safe for concurrent use in worker threads.
16+
317
## 7.0.0
418

519
Mar 14, 2026

src/parser/lexer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,6 @@ const _tokenTypeNames: string[] = [];
487487
_tokenTypeNames[TokenKind.TEAM] = 'Team';
488488
_tokenTypeNames[TokenKind.MRU] = 'Mru';
489489
_tokenTypeNames[TokenKind.IDENTIFIER] = 'Identifier';
490-
_tokenTypeNames[TokenKind.DOT] = 'DECIMAL';
491490
_tokenTypeNames[TokenKind.EQUAL] = 'EQUAL';
492491
_tokenTypeNames[TokenKind.UNSIGNED_INTEGER] = 'UNSIGNED_INTEGER';
493492
_tokenTypeNames[TokenKind.SIGNED_INTEGER] = 'SIGNED_INTEGER';

src/parser/parser.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,12 @@ const CLAUSE_KEYWORDS = new Set<TokenKind>([
8080
]);
8181

8282
class SoqlParser {
83-
private tokens: Token[] = [];
83+
private tokens: Token[];
8484
private pos: number = 0;
85-
private config: ParseQueryConfig = {};
85+
private config: ParseQueryConfig;
8686

87-
init(tokens: Token[], config: ParseQueryConfig): void {
87+
constructor(tokens: Token[], config: ParseQueryConfig) {
8888
this.tokens = tokens;
89-
this.pos = 0;
9089
this.config = config;
9190
}
9291

@@ -482,7 +481,6 @@ class SoqlParser {
482481

483482
const tryParse = (fn: () => void, cleanup?: () => void) => {
484483
if (this.config.ignoreParseErrors) {
485-
const savedPos = this.pos;
486484
try {
487485
fn();
488486
// After successful parse, check if we're at a valid clause boundary.
@@ -496,7 +494,9 @@ class SoqlParser {
496494
console.log(e);
497495
}
498496
if (cleanup) cleanup();
499-
this.pos = savedPos;
497+
// Don't restore pos — synchronize forward from where the error occurred.
498+
// Restoring to savedPos would land on the clause keyword (a boundary token),
499+
// causing synchronize() to return immediately and leaving the parser stuck.
500500
this.synchronize();
501501
}
502502
} else {
@@ -1073,7 +1073,7 @@ class SoqlParser {
10731073
}
10741074
if (t.kind === TokenKind.NULL) {
10751075
this.advance();
1076-
return { value: 'null', type: 'NULL' };
1076+
return { value: 'NULL', type: 'NULL' };
10771077
}
10781078
if (t.kind === TokenKind.TRUE) {
10791079
return { value: this.advance().text, type: 'TRUE' };
@@ -1617,19 +1617,13 @@ class SoqlParser {
16171617
}
16181618
}
16191619

1620-
// ====================================================================
1621-
// Module-level parser instance, reused per parse call
1622-
// ====================================================================
1623-
1624-
const parser = new SoqlParser();
1625-
16261620
/**
16271621
* Parse a SOQL query string into a Query AST.
16281622
*/
16291623
export function parseQuery(soql: string, options?: ParseQueryConfig): Query {
16301624
const config = options || {};
16311625
const tokens = tokenize(soql);
1632-
parser.init(tokens, config);
1626+
const parser = new SoqlParser(tokens, config);
16331627

16341628
let query: Query;
16351629
if (config.allowPartialQuery) {

src/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ function whereValueHelper(value: any, literalType?: LiteralType) {
258258
case 'STRING': {
259259
return isString(value) && value.startsWith("'") ? value : `'${value ?? ''}'`;
260260
}
261+
case 'NULL': {
262+
return 'null';
263+
}
261264
default: {
262265
return value;
263266
}

test/error-recovery.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { parseQuery } from '../src';
3+
4+
/**
5+
* Tests for error recovery behavior when ignoreParseErrors is true.
6+
*
7+
* There is a bug in tryParse (src/parser/parser.ts): when a clause parse fails,
8+
* the parser restores this.pos to savedPos (the position before the clause keyword
9+
* was consumed), then calls this.synchronize(). But synchronize() returns immediately
10+
* when already at a clause boundary — and the clause keyword IS a clause boundary.
11+
* This means the parser gets stuck at the failed clause's keyword and all subsequent
12+
* clauses are silently dropped.
13+
*/
14+
describe('error recovery with ignoreParseErrors', () => {
15+
it('should still parse ORDER BY and LIMIT after a malformed GROUP BY clause', () => {
16+
// GROUP BY has no fields (malformed), but ORDER BY and LIMIT are valid
17+
const soql = 'SELECT Id FROM Account GROUP BY ORDER BY Id LIMIT 10';
18+
const result = parseQuery(soql, { ignoreParseErrors: true });
19+
20+
// These basic parts should always parse fine
21+
expect(result.fields).toEqual([{ type: 'Field', field: 'Id' }]);
22+
expect(result.sObject).toEqual('Account');
23+
24+
// The malformed GROUP BY should be skipped, but the subsequent valid clauses
25+
// should still be parsed. This assertion will FAIL due to the bug:
26+
// the parser gets stuck at GROUP BY and never reaches ORDER BY or LIMIT.
27+
expect(result.orderBy).toBeDefined();
28+
expect(result.orderBy).toEqual([{ field: 'Id' }]);
29+
expect(result.limit).toBeDefined();
30+
expect(result.limit).toEqual(10);
31+
});
32+
33+
it('should still parse LIMIT after a malformed WHERE clause', () => {
34+
// WHERE is incomplete (no condition after it), but LIMIT is valid
35+
const soql = 'SELECT Id FROM Account WHERE LIMIT 10';
36+
const result = parseQuery(soql, { ignoreParseErrors: true });
37+
38+
expect(result.fields).toEqual([{ type: 'Field', field: 'Id' }]);
39+
expect(result.sObject).toEqual('Account');
40+
41+
// LIMIT should still be parsed even though WHERE failed.
42+
// This will FAIL due to the same bug: the parser gets stuck at WHERE.
43+
expect(result.limit).toBeDefined();
44+
expect(result.limit).toEqual(10);
45+
});
46+
47+
it('should still parse LIMIT and OFFSET after a malformed ORDER BY clause', () => {
48+
// ORDER BY has no fields (malformed), but LIMIT and OFFSET are valid
49+
const soql = 'SELECT Id FROM Account ORDER BY LIMIT 10 OFFSET 5';
50+
const result = parseQuery(soql, { ignoreParseErrors: true });
51+
52+
expect(result.fields).toEqual([{ type: 'Field', field: 'Id' }]);
53+
expect(result.sObject).toEqual('Account');
54+
55+
// These should be parsed despite the malformed ORDER BY.
56+
// This will FAIL due to the synchronize bug.
57+
expect(result.limit).toBeDefined();
58+
expect(result.limit).toEqual(10);
59+
expect(result.offset).toBeDefined();
60+
expect(result.offset).toEqual(5);
61+
});
62+
});

test/lexer.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -689,8 +689,8 @@ describe('Lexer - tokenTypeName', () => {
689689
expect(tokenTypeName(TokenKind.CONVERT_CURRENCY)).toBe('convertCurrency');
690690
});
691691

692-
it('should return "DECIMAL" for DOT', () => {
693-
expect(tokenTypeName(TokenKind.DOT)).toBe('DECIMAL');
692+
it('should return "DOT" for DOT', () => {
693+
expect(tokenTypeName(TokenKind.DOT)).toBe('DOT');
694694
});
695695

696696
it('should return "new" for APEX_NEW', () => {

test/test-cases.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -901,7 +901,7 @@ export const testCases: TestCase[] = [
901901
right: {
902902
left: { field: 'Baz', operator: 'IN', value: ['101.00', '102.50'], literalType: 'DECIMAL' },
903903
operator: 'OR',
904-
right: { left: { field: 'Bam', operator: 'IN', value: ["'FOO'", 'null'], literalType: ['STRING', 'NULL'] } },
904+
right: { left: { field: 'Bam', operator: 'IN', value: ["'FOO'", 'NULL'], literalType: ['STRING', 'NULL'] } },
905905
},
906906
},
907907
},
@@ -1084,7 +1084,7 @@ export const testCases: TestCase[] = [
10841084
left: {
10851085
field: 'Industry',
10861086
operator: 'IN',
1087-
value: ["'media'", 'null', '1', "'media'", '2'],
1087+
value: ["'media'", 'NULL', '1', "'media'", '2'],
10881088
literalType: ['STRING', 'NULL', 'INTEGER', 'STRING', 'INTEGER'],
10891089
},
10901090
},
@@ -1547,7 +1547,7 @@ export const testCases: TestCase[] = [
15471547
closeParen: 1,
15481548
field: 'Name',
15491549
operator: 'IN',
1550-
value: [`'4/30 testing account'`, `'amendment quote doc testing'`, 'null'],
1550+
value: [`'4/30 testing account'`, `'amendment quote doc testing'`, 'NULL'],
15511551
literalType: ['STRING', 'STRING', 'NULL'],
15521552
},
15531553
},

0 commit comments

Comments
 (0)