From 48b9ae4bdf164f44ef9401fc56067f2ff44e0f24 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 1 Oct 2026 22:02:43 +0100 Subject: [PATCH 1/2] feat: support documented SOQL SET OPTIONS and query options binds --- antlr/BaseApexLexer.g4 | 3 + antlr/BaseApexParser.g4 | 19 + doc/soql-set-options.md | 361 ++++++++++++++++++ jvm/antlr/ApexParser.g4 | 12 + .../apexparser/SOQLParserTest.java | 187 +++++++++ npm/antlr/ApexParser.g4 | 14 + npm/test/SOQLParserTest.ts | 165 ++++++++ 7 files changed, 761 insertions(+) create mode 100644 doc/soql-set-options.md diff --git a/antlr/BaseApexLexer.g4 b/antlr/BaseApexLexer.g4 index ec42914..e8afaa3 100644 --- a/antlr/BaseApexLexer.g4 +++ b/antlr/BaseApexLexer.g4 @@ -149,6 +149,9 @@ HAVING : 'having'; ROLLUP : 'rollup'; TOLABEL : 'tolabel'; OFFSET : 'offset'; +OPTIONS : 'options'; +DATASPACE : 'dataspace'; +HONOR_EMPTY_STRINGS : 'honoremptystrings'; DATA : 'data'; CATEGORY : 'category'; AT : 'at'; diff --git a/antlr/BaseApexParser.g4 b/antlr/BaseApexParser.g4 index 15c90a3..ef8eb89 100644 --- a/antlr/BaseApexParser.g4 +++ b/antlr/BaseApexParser.g4 @@ -598,6 +598,7 @@ query allRowsClause? forClauses (UPDATE updateList)? + setOptionsClause? ; subQuery @@ -819,6 +820,18 @@ forClauses boundExpression : COLON expression; +// SET OPTIONS is terminal. Literal options are for standalone SOQL, not inline Apex. +// explicitNamespace is configured through Database.QueryOptions, not a literal option. +setOptionsClause + : SET OPTIONS ({this.isStandaloneQuery()}? LPAREN queryOption (COMMA queryOption)* RPAREN + | boundExpression {!this.isStandaloneQuery() || this.isQueryOptionsBind($boundExpression.ctx)}?) + ; + +queryOption + : DATASPACE ASSIGN StringLiteral + | HONOR_EMPTY_STRINGS ASSIGN BooleanLiteral + ; + dateFormula : YESTERDAY | TODAY @@ -1007,6 +1020,9 @@ id | ROLLUP | TOLABEL | OFFSET + | OPTIONS + | DATASPACE + | HONOR_EMPTY_STRINGS | DATA | CATEGORY | AT @@ -1210,6 +1226,9 @@ anyId | ROLLUP | TOLABEL | OFFSET + | OPTIONS + | DATASPACE + | HONOR_EMPTY_STRINGS | DATA | CATEGORY | AT diff --git a/doc/soql-set-options.md b/doc/soql-set-options.md new file mode 100644 index 0000000..731f01c --- /dev/null +++ b/doc/soql-set-options.md @@ -0,0 +1,361 @@ +# SOQL SET OPTIONS support + +Evidence checked on 2026-10-01 for [issue #165](https://github.com/apex-dev-tools/apex-parser/issues/165). + +The [SOQL SET OPTIONS reference](https://developer.salesforce.com/docs/platform/salesforce-soql-sosl/guide/sforce-api-calls-soql-select-set-options.html) documents a parenthesized, comma-separated list of assignments: `dataspace` takes a single-quoted string and `honorEmptyStrings` takes `true` or `false`. The code examples are in the page HTML's `dx-code-block` attributes, which some text renderers omit. The [Summer ’26 developer guide](https://developer.salesforce.com/blogs/2026/06/the-salesforce-developers-guide-to-the-summer-26-release) places the clause at the very end of the query. This implementation appends it after the existing query tail and does not add it to subqueries. + +The [Winter ’27 managed query release note](https://help.salesforce.com/s/articleView?id=release-notes.rn_apex_namespace_shadowing_managed_packages.htm&language=en_US&type=5) shows a whole-clause `:opts` bind supplied through `Database.query()` or `Database.queryWithBinds()`. Its builder enables namespace handling with a Boolean. The SOQL reference restricts that namespace behavior to managed dynamic Apex. No namespace-string assignment syntax is implemented. The linked Apex developer/reference pages rendered empty shells during this investigation; the accessible SOQL page and release note supply the primary evidence. + +## Supported entry points + +| Entry point | Supported options | +| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `query()` (standalone SOQL, including separately parsed dynamic query text) | Literal list with `dataspace` / `honorEmptyStrings`, or whole-clause simple identifier bind | +| `soqlLiteral()` and enclosing Apex rules | Whole-clause `boundExpression`, including ordinary Apex expressions; verified live with an identifier and a method call | +| `subQuery()` | No SET OPTIONS addition | + +Inline bind support is based on actual API 68.0 compilation/execution below, using an empty `Database.QueryOptions` object. This establishes the bind syntax, **not** managed namespace semantics in inline Apex. Inline literal lists are excluded: they failed live compilation. Standalone query parsing does not identify whether a caller will send the text to REST or dynamic Apex; the latter rejected literal lists in the live probe. Callers must apply execution-context restrictions. + +Object eligibility is semantic, not inferred from object-name suffixes. The reference requires a dataspace to obtain DLO records and excludes that option from DMO queries; `honorEmptyStrings` also supports simple DMO queries. The grammar cannot establish object metadata, query execution behavior, option object types, or managed packaging. It does not enforce an API gate: the note's API 34.0 mention concerns an older duplicate-field rule, not the introduction of SET OPTIONS. + +`queryWithBinds()` resolves its placeholder from a map key. The live `queryOptionsKey` probe deliberately has no same-named Apex variable. Parsing standalone text produces an identifier expression beneath `boundExpression`; this supplies syntax and traversal, not variable or map-key resolution. Dynamic string literals in Apex remain string literals and are not parsed as SOQL automatically. + +## AST compatibility and downstream adoption + +Existing `SoqlLiteralContext.query()`, `QueryContext.selectList()`, `fromNameList()`, grouping/function contexts, and expression subclasses remain intact. `setOptionsClause()` adds a child/accessor and literal assignments have `queryOption()` children. The whole-clause bind remains a `BoundExpressionContext` with its existing `expression()` child. Both target regressions check visitor discovery of WHERE and options binds, inline bind expressions, and query shape. Generated rule/token numbers change; consumers must regenerate/recompile rather than relying on numeric values. + +Read-only inspection of apex-ls `SOQL.apply` shows result classification still derives from the existing SELECT/function/grouping nodes and `BoundExprVisitor` traverses the query for `boundExpression`. A justified apex-ls follow-up is dependency adoption plus inline options-bind and standalone query regressions, asserting list/count/aggregate result classification and bind traversal. Platform `Database.QueryOptions` declarations belong to [apex-ls #595](https://github.com/apex-dev-tools/apex-ls/issues/595). Dynamic query map keys must not become static variable references; subscriber namespace resolution must not be emulated. No apex-ls files were changed or analyzer reproduction performed. + +## Live verification + +Used Salesforce CLI 2.140.6, org alias `pre-release`, connected instance API 68.0. All requests explicitly used `--api-version 68.0`; only read-only queries and anonymous Apex were executed, with no persistent changes. Baseline `SELECT Id FROM Account LIMIT 1` returned one record. A successful EntityDefinition query for `__dlm` / `__dll` names returned no records: no Data 360 objects were available/discoverable to this user. Thus actual DLO/DMO behavior and managed-package/subscriber namespace resolution remain unverified. Successful namespace-option calls in this org do not prove those semantics or broaden the documented managed-dynamic restriction. + +Exact probes and sanitized outcomes follow. Record contents, org identity, and authentication material are omitted. Salesforce's inline compiler reports indirect error locations, preserved here without reinterpreting them as precise grammar diagnostics. + +### REST probe + +```sql +SELECT Id FROM Account LIMIT 1 SET OPTIONS (honorEmptyStrings = true) +``` + +```json +{ + "status": 0, + "result": { + "totalSize": 1 + } +} +``` + +### REST probe + +```sql +SELECT Id FROM Account LIMIT 1 SET OPTIONS (dataspace = 'default') +``` + +```json +{ + "status": 0, + "result": { + "totalSize": 1 + } +} +``` + +### REST probe + +```sql +SELECT Id FROM Account LIMIT 1 SET OPTIONS (explicitNamespace = true) +``` + +```json +{ + "status": 0, + "result": { + "totalSize": 1 + } +} +``` + +### REST probe + +```sql +SELECT Id FROM Account SET OPTIONS (honorEmptyStrings = true) LIMIT 1 +``` + +```json +{ + "status": 1, + "name": "MALFORMED_QUERY", + "message": "\nOPTIONS (honorEmptyStrings = true) LIMIT 1\n ^\nERROR at Row:1:Column:62\nunexpected token: 'LIMIT'", + "result": {} +} +``` + +### APEX probe + +```java +List rows = [SELECT Id FROM Account LIMIT 1]; +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "", + "exceptionStackTrace": "", + "line": -1, + "column": -1 + } +} +``` + +### APEX probe + +```java +List rows = [SELECT Id FROM Account LIMIT 1 SET OPTIONS (honorEmptyStrings = true)]; +``` + +```json +{ + "status": 1, + "name": "executeCompileFailure", + "message": "Compilation failed at Line 1 column 5 with the error:\n\nUnexpected token '<'.", + "result": {} +} +``` + +### APEX probe + +```java +List rows = [SELECT Id FROM Account LIMIT 1 SET OPTIONS (dataspace = 'default')]; +``` + +```json +{ + "status": 1, + "name": "executeCompileFailure", + "message": "Compilation failed at Line 1 column 5 with the error:\n\nUnexpected token '<'.", + "result": {} +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().withExplicitNamespace(true).build(); List rows = Database.query('SELECT Id FROM Account LIMIT 1 SET OPTIONS :opts', AccessLevel.USER_MODE); +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "", + "exceptionStackTrace": "", + "line": -1, + "column": -1 + } +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().withExplicitNamespace(true).build(); List rows = Database.queryWithBinds('SELECT Id FROM Account LIMIT 1 SET OPTIONS :opts', new Map{'opts' => opts}, AccessLevel.USER_MODE); +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "", + "exceptionStackTrace": "", + "line": -1, + "column": -1 + } +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().withExplicitNamespace(true).build(); List rows = [SELECT Id FROM Account LIMIT 1 SET OPTIONS :opts]; +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "", + "exceptionStackTrace": "", + "line": -1, + "column": -1 + } +} +``` + +### APEX probe + +```java +List rows = Database.query('SELECT Id FROM Account LIMIT 1 SET OPTIONS (honorEmptyStrings = true)', AccessLevel.USER_MODE); +``` + +```json +{ + "status": 1, + "name": "executeRuntimeFailure", + "message": "Execution failed at this code:\n\nSystem.QueryException: unexpected token: 'SET'", + "result": {} +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().build(); List rows = [SELECT Id FROM Account LIMIT 1 SET OPTIONS :opts]; +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "" + } +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().build(); List rows = [SELECT Id FROM Account LIMIT 1 SET OPTIONS :opts]; System.assertEquals(1, rows.size()); +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "" + } +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().build(); List rows = Database.query('SELECT Id FROM Account LIMIT 1 SET OPTIONS :opts', AccessLevel.USER_MODE); +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "" + } +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().build(); List rows = [SELECT Id FROM Account SET OPTIONS :opts LIMIT 1]; +``` + +```json +{ + "status": 1, + "name": "executeCompileFailure", + "message": "Compilation failed at Line 1 column 75 with the error:\n\nUnexpected token '<'.", + "result": {} +} +``` + +### APEX probe + +```java +Object rows = [SELECT Id FROM Account LIMIT 1 SET OPTIONS (honorEmptyStrings = true)]; +``` + +```json +{ + "status": 1, + "name": "executeCompileFailure", + "message": "Compilation failed at Line 1 column 8 with the error:\n\nUnexpected token 'rows'.", + "result": {} +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().build(); Map binds = new Map{'queryOptionsKey' => opts}; List rows = Database.queryWithBinds('SELECT Id FROM Account LIMIT 1 SET OPTIONS :queryOptionsKey', binds, AccessLevel.USER_MODE); +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "" + } +} +``` + +### APEX probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().build(); Map holder = new Map{'opts' => opts}; List rows = [SELECT Id FROM Account LIMIT 1 SET OPTIONS :holder.get('opts')]; +``` + +```json +{ + "status": 0, + "result": { + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "" + } +} +``` + +### APEX result typing probe + +```java +Database.QueryOptions opts = Database.QueryOptions.builder().build(); +Integer count = [SELECT COUNT() FROM Account SET OPTIONS :opts]; +List grouped = [SELECT Name, COUNT(Id) FROM Account GROUP BY Name LIMIT 1 SET OPTIONS :opts]; +System.assert(count > 0); +System.assertEquals(1, grouped.size()); +``` + +```json +{ + "compiled": true, + "success": true, + "compileProblem": "", + "exceptionMessage": "" +} +``` diff --git a/jvm/antlr/ApexParser.g4 b/jvm/antlr/ApexParser.g4 index 67fac12..3bdc257 100644 --- a/jvm/antlr/ApexParser.g4 +++ b/jvm/antlr/ApexParser.g4 @@ -3,6 +3,18 @@ options { tokenVocab = ApexLexer; } @parser::members { public void clearCache() { _interp.clearDFA(); } + +private boolean isStandaloneQuery() { + for (org.antlr.v4.runtime.RuleContext ctx = _ctx; ctx != null; ctx = ctx.parent) { + if (ctx.getRuleIndex() == RULE_soqlLiteral) return false; + } + return true; +} + +private boolean isQueryOptionsBind(BoundExpressionContext ctx) { + return ctx.expression() instanceof PrimaryExpressionContext + && ((PrimaryExpressionContext) ctx.expression()).primary() instanceof IdPrimaryContext; +} } import BaseApexParser; diff --git a/jvm/src/test/java/io/github/apexdevtools/apexparser/SOQLParserTest.java b/jvm/src/test/java/io/github/apexdevtools/apexparser/SOQLParserTest.java index 3f8727c..1898465 100644 --- a/jvm/src/test/java/io/github/apexdevtools/apexparser/SOQLParserTest.java +++ b/jvm/src/test/java/io/github/apexdevtools/apexparser/SOQLParserTest.java @@ -16,8 +16,13 @@ import static io.github.apexdevtools.apexparser.SyntaxErrorCounter.createParser; import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class SOQLParserTest { @@ -290,4 +295,186 @@ void testFormulaFunctionNotAllowedInHaving() { assertNotNull(context); assertTrue(parserAndCounter.getValue().getNumErrors() > 0); } + + // SET OPTIONS examples are standalone/dynamic SOQL, not inline Apex. + @ParameterizedTest + @ValueSource( + strings = { + "SELECT Id, Name FROM MyDLO__dlm WHERE Status__c = 'Active' SET OPTIONS (dataspace = 'default', honorEmptyStrings = true)", + "SELECT AccountId__c, Email__c FROM CustomerProfile__dlm SET OPTIONS (dataspace = 'default')", + "SELECT Id, EmailOptIn__c FROM ContactDLO__dlm WHERE EmailOptIn__c = '' SET OPTIONS (dataspace = 'default', honorEmptyStrings = false)", + "SELECT Id FROM SimpleDMO__dlm SET OPTIONS (honorEmptyStrings = true)", + "SELECT Id FROM SimpleDMO__dlm SET OPTIONS (honorEmptyStrings = false)", + "SELECT Id FROM MyDLO__dlm ORDER BY Id LIMIT 10 OFFSET 1 SET OPTIONS (honorEmptyStrings = true, dataspace = 'default')", + "select Id from MyDLO__dlm set options (DATASPACE = 'default', HONOREMPTYSTRINGS = TRUE)", + "SELECT Id, Name, Age__c, ExPackageNS__Age__c FROM Account SET OPTIONS :opts", + "SELECT Id FROM Account WHERE Name = :name WITH USER_MODE ORDER BY Id LIMIT 10 OFFSET 1 FOR VIEW UPDATE TRACKING SET OPTIONS :opts", + } + ) + void testSetOptionsAccepted(String source) { + Map.Entry pair = createParser(source); + ApexParser parser = pair.getKey(); + ApexParser.QueryContext context = parser.query(); + assertNotNull(context.setOptionsClause()); + assertEquals(0, pair.getValue().getNumErrors()); + assertEquals(-1, parser.getTokenStream().LA(1)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "SELECT Id FROM Account SET OPTIONS", + "SELECT Id FROM Account SET OPTIONS ()", + "SELECT Id FROM Account SET OPTIONS opts", + "SELECT Id FROM Account SET OPTIONS :true", + "SELECT Id FROM Account SET OPTIONS :'package'", + "SELECT Id FROM Account SET OPTIONS :opts.member", + "SELECT Id FROM Account SET OPTIONS :opts + 1", + "SELECT Id FROM Account SET OPTIONS :getOptions()", + "SELECT Id FROM Account SET OPTIONS :", + "SELECT Id FROM Account SET OPTIONS (:opts)", + "SELECT Id FROM Account SET OPTIONS dataspace = 'default'", + "SELECT Id FROM Account SET OPTIONS (dataspace = true)", + "SELECT Id FROM Account SET OPTIONS (dataspace = 1)", + "SELECT Id FROM Account SET OPTIONS (dataspace = :space)", + "SELECT Id FROM Account SET OPTIONS (honorEmptyStrings = 'true')", + "SELECT Id FROM Account SET OPTIONS (honorEmptyStrings = 1)", + "SELECT Id FROM Account SET OPTIONS (honorEmptyStrings = :flag)", + "SELECT Id FROM Account SET OPTIONS (explicitNamespace = true)", + "SELECT Id FROM Account SET OPTIONS (explicitNamespace = 'package')", + "SELECT Id FROM Account SET OPTIONS (explicitNamespace = :packageName)", + "SELECT Id FROM Account SET OPTIONS (unknown = true)", + "SELECT Id FROM Account SET OPTIONS (dataspace = 'default',)", + "SELECT Id FROM Account SET OPTIONS (dataspace = 'default' honorEmptyStrings = true)", + "SELECT Id SET OPTIONS :opts FROM Account", + "SELECT Id FROM Account SET OPTIONS :opts WHERE Name = 'Acme'", + "SELECT Id FROM Account SET OPTIONS :opts ORDER BY Id", + "SELECT Id FROM Account SET OPTIONS :opts LIMIT 1", + "SELECT Id FROM Account SET OPTIONS :opts OFFSET 1", + "SELECT Id FROM Account SET OPTIONS :opts FOR UPDATE", + "SELECT Id FROM Account SET OPTIONS :opts UPDATE TRACKING", + "SELECT Id FROM Account SET OPTIONS :opts SET OPTIONS :other", + "SELECT Id FROM Account SET OPTIONS :opts, dataspace = 'default'", + "SELECT Id, (SELECT Id FROM Contacts SET OPTIONS :opts) FROM Account", + } + ) + void testSetOptionsRejected(String source) { + Map.Entry pair = createParser(source); + ApexParser parser = pair.getKey(); + parser.query(); + // query() can parse a prefix; also check for unconsumed input. + assertTrue( + pair.getValue().getNumErrors() > 0 || parser.getTokenStream().LA(1) != -1 + ); + } + + @ParameterizedTest + @ValueSource( + strings = { "(dataspace = 'default')", "(honorEmptyStrings = true)" } + ) + void testSetOptionsExcludedFromInlineApex(String options) { + Map.Entry pair = createParser( + "[SELECT Id FROM Account SET OPTIONS " + options + "]" + ); + pair.getKey().soqlLiteral(); + assertTrue(pair.getValue().getNumErrors() > 0); + } + + @Test + void testQueryOptionsPreserveShapeAndBindTraversal() { + Map.Entry pair = createParser( + "SELECT Id FROM Account WHERE Name = :name SET OPTIONS :opts" + ); + ApexParser.QueryContext context = pair.getKey().query(); + List binds = new ArrayList<>(); + new ApexParserBaseVisitor() { + @Override + public Void visitBoundExpression(ApexParser.BoundExpressionContext ctx) { + binds.add(ctx.expression().getText()); + return null; + } + }.visit(context); + assertEquals(Arrays.asList("name", "opts"), binds); + assertEquals("Id", context.selectList().getText()); + assertEquals("Account", context.fromNameList().getText()); + assertEquals( + "opts", + context.setOptionsClause().boundExpression().expression().getText() + ); + assertEquals(0, pair.getValue().getNumErrors()); + } + + @Test + void testOptionKeywordsRemainIdentifiers() { + Map.Entry pair = createParser( + "SELECT options, dataspace, honorEmptyStrings FROM Account" + ); + pair.getKey().query(); + assertEquals(0, pair.getValue().getNumErrors()); + pair = createParser( + "Integer options = 1; String dataspace = 'default'; Boolean honorEmptyStrings = true; List records = [SELECT Id FROM Account];" + ); + pair.getKey().anonymousUnit(); + assertEquals(0, pair.getValue().getNumErrors()); + } + + @ParameterizedTest + @ValueSource(strings = { "opts", "holder.get('opts')" }) + void testLiveVerifiedInlineOptionsBind(String expression) { + Map.Entry pair = createParser( + "[SELECT Id FROM Account LIMIT 1 SET OPTIONS :" + expression + "]" + ); + ApexParser.SoqlLiteralContext literal = pair.getKey().soqlLiteral(); + assertNotNull(literal.query()); + assertEquals("Account", literal.query().fromNameList().getText()); + assertEquals( + expression, + literal + .query() + .setOptionsClause() + .boundExpression() + .expression() + .getText() + ); + List binds = new ArrayList<>(); + new ApexParserBaseVisitor() { + @Override + public Void visitBoundExpression(ApexParser.BoundExpressionContext ctx) { + binds.add(ctx.expression().getText()); + return null; + } + }.visit(literal); + assertEquals(Arrays.asList(expression), binds); + assertEquals(0, pair.getValue().getNumErrors()); + assertEquals(-1, pair.getKey().getTokenStream().LA(1)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "SELECT COUNT() FROM Account SET OPTIONS :opts", + "SELECT Name, COUNT(Id) FROM Account GROUP BY Name LIMIT 1 SET OPTIONS :opts", + } + ) + void testOptionsRetainCountAndAggregateShape(String source) { + Map.Entry pair = createParser( + "[" + source + "]" + ); + ApexParser.QueryContext query = pair.getKey().soqlLiteral().query(); + boolean grouped = source.contains("GROUP BY"); + ApexParser.SoqlFunctionContext fn = query + .selectList() + .selectEntry(grouped ? 1 : 0) + .soqlFunction(); + assertNotNull(fn.COUNT()); + if (grouped) { + assertEquals("Id", fn.fieldName().getText()); + assertEquals("GROUPBYName", query.groupByClause().getText()); + } else { + assertNull(fn.fieldName()); + assertNull(query.groupByClause()); + } + assertEquals("Account", query.fromNameList().getText()); + assertEquals(0, pair.getValue().getNumErrors()); + } } diff --git a/npm/antlr/ApexParser.g4 b/npm/antlr/ApexParser.g4 index 9ed55d5..02e0fd1 100644 --- a/npm/antlr/ApexParser.g4 +++ b/npm/antlr/ApexParser.g4 @@ -1,4 +1,18 @@ parser grammar ApexParser; options { tokenVocab = ApexLexer; } +@parser::members { +private isStandaloneQuery(): boolean { + for (let ctx: ParserRuleContext | null | undefined = this._ctx; ctx != null; ctx = ctx.parentCtx) { + if (ctx instanceof SoqlLiteralContext) return false; + } + return true; +} + +private isQueryOptionsBind(ctx: BoundExpressionContext): boolean { + const expr = ctx.expression(); + return expr instanceof PrimaryExpressionContext && expr.primary() instanceof IdPrimaryContext; +} +} + import BaseApexParser; diff --git a/npm/test/SOQLParserTest.ts b/npm/test/SOQLParserTest.ts index 7d8416b..d1c553a 100644 --- a/npm/test/SOQLParserTest.ts +++ b/npm/test/SOQLParserTest.ts @@ -16,6 +16,8 @@ import { StatementContext, SoqlLiteralContext, } from "../src/antlr/ApexParser.js"; +import { ApexParserBaseVisitor } from "../src/ApexParserFactory.js"; +import { BoundExpressionContext } from "../src/antlr/ApexParser.js"; import { createParser } from "./SyntaxErrorCounter.js"; test("SOQL Query", () => { @@ -285,3 +287,166 @@ test("testFormulaFunctionNotAllowedInHaving", () => { expect(context).toBeInstanceOf(QueryContext); expect(errorCounter.getNumErrors()).toBeGreaterThan(0); }); + +// SET OPTIONS examples are standalone/dynamic SOQL, not inline Apex. +test.each([ + "SELECT Id, Name FROM MyDLO__dlm WHERE Status__c = 'Active' SET OPTIONS (dataspace = 'default', honorEmptyStrings = true)", + "SELECT AccountId__c, Email__c FROM CustomerProfile__dlm SET OPTIONS (dataspace = 'default')", + "SELECT Id, EmailOptIn__c FROM ContactDLO__dlm WHERE EmailOptIn__c = '' SET OPTIONS (dataspace = 'default', honorEmptyStrings = false)", + "SELECT Id FROM SimpleDMO__dlm SET OPTIONS (honorEmptyStrings = true)", + "SELECT Id FROM SimpleDMO__dlm SET OPTIONS (honorEmptyStrings = false)", + "SELECT Id FROM MyDLO__dlm ORDER BY Id LIMIT 10 OFFSET 1 SET OPTIONS (honorEmptyStrings = true, dataspace = 'default')", + "select Id from MyDLO__dlm set options (DATASPACE = 'default', HONOREMPTYSTRINGS = TRUE)", + "SELECT Id, Name, Age__c, ExPackageNS__Age__c FROM Account SET OPTIONS :opts", + "SELECT Id FROM Account WHERE Name = :name WITH USER_MODE ORDER BY Id LIMIT 10 OFFSET 1 FOR VIEW UPDATE TRACKING SET OPTIONS :opts", +])("SET OPTIONS accepts %s", source => { + const [parser, errors] = createParser(source); + const context = parser.query(); + expect(context).toBeInstanceOf(QueryContext); + expect(context.setOptionsClause()).not.toBeNull(); + expect(errors.getNumErrors()).toBe(0); + expect(parser.getTokenStream().LA(1)).toBe(-1); +}); +test.each([ + "SELECT Id FROM Account SET OPTIONS", + "SELECT Id FROM Account SET OPTIONS ()", + "SELECT Id FROM Account SET OPTIONS opts", + "SELECT Id FROM Account SET OPTIONS :true", + "SELECT Id FROM Account SET OPTIONS :'package'", + "SELECT Id FROM Account SET OPTIONS :opts.member", + "SELECT Id FROM Account SET OPTIONS :opts + 1", + "SELECT Id FROM Account SET OPTIONS :getOptions()", + "SELECT Id FROM Account SET OPTIONS :", + "SELECT Id FROM Account SET OPTIONS (:opts)", + "SELECT Id FROM Account SET OPTIONS dataspace = 'default'", + "SELECT Id FROM Account SET OPTIONS (dataspace = true)", + "SELECT Id FROM Account SET OPTIONS (dataspace = 1)", + "SELECT Id FROM Account SET OPTIONS (dataspace = :space)", + "SELECT Id FROM Account SET OPTIONS (honorEmptyStrings = 'true')", + "SELECT Id FROM Account SET OPTIONS (honorEmptyStrings = 1)", + "SELECT Id FROM Account SET OPTIONS (honorEmptyStrings = :flag)", + "SELECT Id FROM Account SET OPTIONS (explicitNamespace = true)", + "SELECT Id FROM Account SET OPTIONS (explicitNamespace = 'package')", + "SELECT Id FROM Account SET OPTIONS (explicitNamespace = :packageName)", + "SELECT Id FROM Account SET OPTIONS (unknown = true)", + "SELECT Id FROM Account SET OPTIONS (dataspace = 'default',)", + "SELECT Id FROM Account SET OPTIONS (dataspace = 'default' honorEmptyStrings = true)", + "SELECT Id SET OPTIONS :opts FROM Account", + "SELECT Id FROM Account SET OPTIONS :opts WHERE Name = 'Acme'", + "SELECT Id FROM Account SET OPTIONS :opts ORDER BY Id", + "SELECT Id FROM Account SET OPTIONS :opts LIMIT 1", + "SELECT Id FROM Account SET OPTIONS :opts OFFSET 1", + "SELECT Id FROM Account SET OPTIONS :opts FOR UPDATE", + "SELECT Id FROM Account SET OPTIONS :opts UPDATE TRACKING", + "SELECT Id FROM Account SET OPTIONS :opts SET OPTIONS :other", + "SELECT Id FROM Account SET OPTIONS :opts, dataspace = 'default'", + "SELECT Id, (SELECT Id FROM Contacts SET OPTIONS :opts) FROM Account", +])("SET OPTIONS rejects %s", source => { + const [parser, errors] = createParser(source); + parser.query(); + // query() can parse a prefix; rejection must also check for unconsumed input. + expect( + errors.getNumErrors() > 0 || parser.getTokenStream().LA(1) !== -1 + ).toBe(true); +}); + +test.each(["(dataspace = 'default')", "(honorEmptyStrings = true)"])( + "SET OPTIONS literals excluded from inline Apex: %s", + options => { + const [parser, errors] = createParser( + `[SELECT Id FROM Account SET OPTIONS ${options}]` + ); + parser.soqlLiteral(); + expect(errors.getNumErrors()).toBeGreaterThan(0); + } +); + +test("Query options preserve query shape and boundExpression traversal", () => { + const [parser, errors] = createParser( + "SELECT Id FROM Account WHERE Name = :name SET OPTIONS :opts" + ); + const context = parser.query(); + class BindVisitor extends ApexParserBaseVisitor { + binds: string[] = []; + visitBoundExpression(ctx: BoundExpressionContext): void { + this.binds.push(ctx.expression().getText()); + } + } + const visitor = new BindVisitor(); + visitor.visit(context); + expect(visitor.binds).toEqual(["name", "opts"]); + expect(context.selectList().getText()).toBe("Id"); + expect(context.fromNameList().getText()).toBe("Account"); + expect( + context.setOptionsClause().boundExpression().expression().getText() + ).toBe("opts"); + expect(errors.getNumErrors()).toBe(0); +}); + +test("New option keywords remain Apex and SOQL identifiers", () => { + const [parser, errors] = createParser( + "SELECT options, dataspace, honorEmptyStrings FROM Account" + ); + parser.query(); + expect(errors.getNumErrors()).toBe(0); + const [apex, apexErrors] = createParser( + "Integer options = 1; String dataspace = 'default'; Boolean honorEmptyStrings = true; List records = [SELECT Id FROM Account];" + ); + apex.anonymousUnit(); + expect(apexErrors.getNumErrors()).toBe(0); +}); + +test.each(["opts", "holder.get('opts')"])( + "Live-verified inline query options bind: %s", + expression => { + const [parser, errors] = createParser( + `[SELECT Id FROM Account LIMIT 1 SET OPTIONS :${expression}]` + ); + const literal = parser.soqlLiteral(); + expect(literal).toBeInstanceOf(SoqlLiteralContext); + expect(literal.query()).toBeInstanceOf(QueryContext); + expect(literal.query().fromNameList().getText()).toBe("Account"); + expect( + literal + .query() + .setOptionsClause() + .boundExpression() + .expression() + .getText() + ).toBe(expression); + class BindVisitor extends ApexParserBaseVisitor { + binds: string[] = []; + visitBoundExpression(ctx: BoundExpressionContext): void { + this.binds.push(ctx.expression().getText()); + } + } + const visitor = new BindVisitor(); + visitor.visit(literal); + expect(visitor.binds).toEqual([expression]); + expect(errors.getNumErrors()).toBe(0); + expect(parser.getTokenStream().LA(1)).toBe(-1); + } +); + +test.each([ + "SELECT COUNT() FROM Account SET OPTIONS :opts", + "SELECT Name, COUNT(Id) FROM Account GROUP BY Name LIMIT 1 SET OPTIONS :opts", +])("Options retain count/aggregate result-shape nodes: %s", source => { + const [parser, errors] = createParser(`[${source}]`); + const query = parser.soqlLiteral().query(); + const grouped = source.includes("GROUP BY"); + const fn = query + .selectList() + .selectEntry(grouped ? 1 : 0) + .soqlFunction(); + expect(fn.COUNT()).not.toBeNull(); + if (grouped) { + expect(fn.fieldName().getText()).toBe("Id"); + expect(query.groupByClause().getText()).toBe("GROUPBYName"); + } else { + expect(fn.fieldName()).toBeNull(); + expect(query.groupByClause()).toBeNull(); + } + expect(query.fromNameList().getText()).toBe("Account"); + expect(errors.getNumErrors()).toBe(0); +}); From a9d590853b98a8d6f9d25c47f636a00f10f915e5 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 1 Oct 2026 23:07:25 +0100 Subject: [PATCH 2/2] fix: preserve normal SOQL query options bind expressions --- antlr/BaseApexParser.g4 | 2 +- doc/soql-set-options.md | 14 ++++- jvm/antlr/ApexParser.g4 | 5 -- .../apexparser/SOQLParserTest.java | 58 +++++++++++++++++-- npm/antlr/ApexParser.g4 | 5 -- npm/test/SOQLParserTest.ts | 44 ++++++++++++-- 6 files changed, 104 insertions(+), 24 deletions(-) diff --git a/antlr/BaseApexParser.g4 b/antlr/BaseApexParser.g4 index ef8eb89..8bbd0d1 100644 --- a/antlr/BaseApexParser.g4 +++ b/antlr/BaseApexParser.g4 @@ -824,7 +824,7 @@ boundExpression // explicitNamespace is configured through Database.QueryOptions, not a literal option. setOptionsClause : SET OPTIONS ({this.isStandaloneQuery()}? LPAREN queryOption (COMMA queryOption)* RPAREN - | boundExpression {!this.isStandaloneQuery() || this.isQueryOptionsBind($boundExpression.ctx)}?) + | boundExpression) ; queryOption diff --git a/doc/soql-set-options.md b/doc/soql-set-options.md index 731f01c..46edc3e 100644 --- a/doc/soql-set-options.md +++ b/doc/soql-set-options.md @@ -10,7 +10,7 @@ The [Winter ’27 managed query release note](https://help.salesforce.com/s/arti | Entry point | Supported options | | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `query()` (standalone SOQL, including separately parsed dynamic query text) | Literal list with `dataspace` / `honorEmptyStrings`, or whole-clause simple identifier bind | +| `query()` (standalone SOQL, including separately parsed dynamic query text) | Literal list with `dataspace` / `honorEmptyStrings`, or whole-clause `boundExpression` | | `soqlLiteral()` and enclosing Apex rules | Whole-clause `boundExpression`, including ordinary Apex expressions; verified live with an identifier and a method call | | `subQuery()` | No SET OPTIONS addition | @@ -18,11 +18,19 @@ Inline bind support is based on actual API 68.0 compilation/execution below, usi Object eligibility is semantic, not inferred from object-name suffixes. The reference requires a dataspace to obtain DLO records and excludes that option from DMO queries; `honorEmptyStrings` also supports simple DMO queries. The grammar cannot establish object metadata, query execution behavior, option object types, or managed packaging. It does not enforce an API gate: the note's API 34.0 mention concerns an older duplicate-field rule, not the introduction of SET OPTIONS. -`queryWithBinds()` resolves its placeholder from a map key. The live `queryOptionsKey` probe deliberately has no same-named Apex variable. Parsing standalone text produces an identifier expression beneath `boundExpression`; this supplies syntax and traversal, not variable or map-key resolution. Dynamic string literals in Apex remain string literals and are not parsed as SOQL automatically. +`queryWithBinds()` resolves its placeholder from a map key. The live `queryOptionsKey` probe deliberately has no same-named Apex variable. Parsing a placeholder produces an identifier expression beneath `boundExpression`; this supplies syntax and traversal, not variable or map-key resolution. Dynamic string literals in Apex remain string literals and are not parsed as SOQL automatically. + +## Bind parsing and semantic validation + +Every entry point uses the existing `boundExpression : COLON expression` rule for the options bind, consistent with WHERE, LIMIT, OFFSET, and other binds. Parenthesized expressions, member access, and calls are parsed, as are syntactically valid expressions whose values would be inappropriate for `Database.QueryOptions`, such as `:true`, `:'package'`, or `:opts + 1`. These cases preserve expression nodes and visitor traversal so consumers can give type or context guidance. This does not add namespace-string option syntax or establish that these expressions are legal in dynamic SOQL. The live evidence establishes only the specific probes recorded below. + +The parser does not impose an identifier-only predicate based on the query entry rule: standalone parsing cannot distinguish dynamic query text from an extracted inline fragment. Removing that predicate avoids a generic ANTLR failed-predicate diagnostic at the end of an otherwise parsed expression and keeps ordinary error recovery. Missing colons/expressions, incomplete expressions, invalid literal-list syntax, and misplaced clauses remain syntax or full-input consumption failures; standalone `query()` callers must check for trailing input because the rule can parse a prefix. + +Consumers should validate options-bind value types, supported expression/placeholder forms for the known execution API, and managed execution context. In inline Apex this includes normal variable/member/method/operator validation and a `Database.QueryOptions` type check. For `queryWithBinds()` it means validating the supplied map key/value without resolving the key as an Apex variable. Current apex-ls performs general expression verification but has no options-specific type/context check and does not parse arbitrary dynamic query strings. A downstream options validator would need to retain/access `setOptionsClause().boundExpression()` separately from the existing flattened bind list. None of these semantic checks are added here. ## AST compatibility and downstream adoption -Existing `SoqlLiteralContext.query()`, `QueryContext.selectList()`, `fromNameList()`, grouping/function contexts, and expression subclasses remain intact. `setOptionsClause()` adds a child/accessor and literal assignments have `queryOption()` children. The whole-clause bind remains a `BoundExpressionContext` with its existing `expression()` child. Both target regressions check visitor discovery of WHERE and options binds, inline bind expressions, and query shape. Generated rule/token numbers change; consumers must regenerate/recompile rather than relying on numeric values. +Existing `SoqlLiteralContext.query()`, `QueryContext.selectList()`, `fromNameList()`, grouping/function contexts, and expression subclasses remain intact. `setOptionsClause()` adds a child/accessor and literal assignments have `queryOption()` children. The whole-clause bind remains a `BoundExpressionContext` with its existing `expression()` child. Both target regressions check visitor discovery of WHERE and options binds, ordinary and semantically inappropriate bind-expression shapes in both entry points, and query shape. Generated rule/token numbers change; consumers must regenerate/recompile rather than relying on numeric values. Read-only inspection of apex-ls `SOQL.apply` shows result classification still derives from the existing SELECT/function/grouping nodes and `BoundExprVisitor` traverses the query for `boundExpression`. A justified apex-ls follow-up is dependency adoption plus inline options-bind and standalone query regressions, asserting list/count/aggregate result classification and bind traversal. Platform `Database.QueryOptions` declarations belong to [apex-ls #595](https://github.com/apex-dev-tools/apex-ls/issues/595). Dynamic query map keys must not become static variable references; subscriber namespace resolution must not be emulated. No apex-ls files were changed or analyzer reproduction performed. diff --git a/jvm/antlr/ApexParser.g4 b/jvm/antlr/ApexParser.g4 index 3bdc257..d8f5682 100644 --- a/jvm/antlr/ApexParser.g4 +++ b/jvm/antlr/ApexParser.g4 @@ -10,11 +10,6 @@ private boolean isStandaloneQuery() { } return true; } - -private boolean isQueryOptionsBind(BoundExpressionContext ctx) { - return ctx.expression() instanceof PrimaryExpressionContext - && ((PrimaryExpressionContext) ctx.expression()).primary() instanceof IdPrimaryContext; -} } import BaseApexParser; diff --git a/jvm/src/test/java/io/github/apexdevtools/apexparser/SOQLParserTest.java b/jvm/src/test/java/io/github/apexdevtools/apexparser/SOQLParserTest.java index 1898465..e85d3d3 100644 --- a/jvm/src/test/java/io/github/apexdevtools/apexparser/SOQLParserTest.java +++ b/jvm/src/test/java/io/github/apexdevtools/apexparser/SOQLParserTest.java @@ -326,12 +326,11 @@ void testSetOptionsAccepted(String source) { "SELECT Id FROM Account SET OPTIONS", "SELECT Id FROM Account SET OPTIONS ()", "SELECT Id FROM Account SET OPTIONS opts", - "SELECT Id FROM Account SET OPTIONS :true", - "SELECT Id FROM Account SET OPTIONS :'package'", - "SELECT Id FROM Account SET OPTIONS :opts.member", - "SELECT Id FROM Account SET OPTIONS :opts + 1", - "SELECT Id FROM Account SET OPTIONS :getOptions()", "SELECT Id FROM Account SET OPTIONS :", + "SELECT Id FROM Account SET OPTIONS :opts +", + "SELECT Id FROM Account SET OPTIONS :(opts", + "SELECT Id FROM Account SET OPTIONS :opts.member(", + "SELECT Id FROM Account SET OPTIONS :opts + 1 LIMIT 1", "SELECT Id FROM Account SET OPTIONS (:opts)", "SELECT Id FROM Account SET OPTIONS dataspace = 'default'", "SELECT Id FROM Account SET OPTIONS (dataspace = true)", @@ -477,4 +476,53 @@ void testOptionsRetainCountAndAggregateShape(String source) { assertEquals("Account", query.fromNameList().getText()); assertEquals(0, pair.getValue().getNumErrors()); } + + // Parser acceptance does not guarantee QueryOptions type or dynamic SOQL legality. + @ParameterizedTest + @ValueSource( + strings = { + "(opts)", + "opts.member", + "getOptions()", + "holder.get('opts')", + "true", + "'package'", + "opts + 1", + } + ) + void testOptionsUseNormalBoundExpressionSyntaxAndTraversal( + String expression + ) { + for (boolean inline : new boolean[] { false, true }) { + String source = + "SELECT Id FROM Account WHERE Name = :name SET OPTIONS :" + expression; + Map.Entry pair = createParser( + inline ? "[" + source + "]" : source + ); + org.antlr.v4.runtime.ParserRuleContext tree = inline + ? pair.getKey().soqlLiteral() + : pair.getKey().query(); + ApexParser.QueryContext query = inline + ? ((ApexParser.SoqlLiteralContext) tree).query() + : (ApexParser.QueryContext) tree; + ApexParser.BoundExpressionContext bind = query + .setOptionsClause() + .boundExpression(); + assertNotNull(bind); + assertEquals(expression.replace(" ", ""), bind.expression().getText()); + List binds = new ArrayList<>(); + new ApexParserBaseVisitor() { + @Override + public Void visitBoundExpression( + ApexParser.BoundExpressionContext ctx + ) { + binds.add(ctx.expression().getText()); + return null; + } + }.visit(tree); + assertEquals(Arrays.asList("name", expression.replace(" ", "")), binds); + assertEquals(0, pair.getValue().getNumErrors()); + assertEquals(-1, pair.getKey().getTokenStream().LA(1)); + } + } } diff --git a/npm/antlr/ApexParser.g4 b/npm/antlr/ApexParser.g4 index 02e0fd1..a0a45c9 100644 --- a/npm/antlr/ApexParser.g4 +++ b/npm/antlr/ApexParser.g4 @@ -8,11 +8,6 @@ private isStandaloneQuery(): boolean { } return true; } - -private isQueryOptionsBind(ctx: BoundExpressionContext): boolean { - const expr = ctx.expression(); - return expr instanceof PrimaryExpressionContext && expr.primary() instanceof IdPrimaryContext; -} } import BaseApexParser; diff --git a/npm/test/SOQLParserTest.ts b/npm/test/SOQLParserTest.ts index d1c553a..73a04d9 100644 --- a/npm/test/SOQLParserTest.ts +++ b/npm/test/SOQLParserTest.ts @@ -311,12 +311,11 @@ test.each([ "SELECT Id FROM Account SET OPTIONS", "SELECT Id FROM Account SET OPTIONS ()", "SELECT Id FROM Account SET OPTIONS opts", - "SELECT Id FROM Account SET OPTIONS :true", - "SELECT Id FROM Account SET OPTIONS :'package'", - "SELECT Id FROM Account SET OPTIONS :opts.member", - "SELECT Id FROM Account SET OPTIONS :opts + 1", - "SELECT Id FROM Account SET OPTIONS :getOptions()", "SELECT Id FROM Account SET OPTIONS :", + "SELECT Id FROM Account SET OPTIONS :opts +", + "SELECT Id FROM Account SET OPTIONS :(opts", + "SELECT Id FROM Account SET OPTIONS :opts.member(", + "SELECT Id FROM Account SET OPTIONS :opts + 1 LIMIT 1", "SELECT Id FROM Account SET OPTIONS (:opts)", "SELECT Id FROM Account SET OPTIONS dataspace = 'default'", "SELECT Id FROM Account SET OPTIONS (dataspace = true)", @@ -450,3 +449,38 @@ test.each([ expect(query.fromNameList().getText()).toBe("Account"); expect(errors.getNumErrors()).toBe(0); }); + +// Parser acceptance is not a guarantee of QueryOptions type or dynamic SOQL legality. +test.each([ + "(opts)", + "opts.member", + "getOptions()", + "holder.get('opts')", + "true", + "'package'", + "opts + 1", +])( + "Options use normal boundExpression syntax and traversal: %s", + expression => { + for (const inline of [false, true]) { + const query = `SELECT Id FROM Account WHERE Name = :name SET OPTIONS :${expression}`; + const [parser, errors] = createParser(inline ? `[${query}]` : query); + const tree = inline ? parser.soqlLiteral() : parser.query(); + const context = tree instanceof SoqlLiteralContext ? tree.query() : tree; + const bind = context.setOptionsClause().boundExpression(); + expect(bind).toBeInstanceOf(BoundExpressionContext); + expect(bind.expression().getText()).toBe(expression.replaceAll(" ", "")); + class BindVisitor extends ApexParserBaseVisitor { + binds: string[] = []; + visitBoundExpression(ctx: BoundExpressionContext): void { + this.binds.push(ctx.expression().getText()); + } + } + const visitor = new BindVisitor(); + visitor.visit(tree); + expect(visitor.binds).toEqual(["name", expression.replaceAll(" ", "")]); + expect(errors.getNumErrors()).toBe(0); + expect(parser.getTokenStream().LA(1)).toBe(-1); + } + } +);