From 4a973ee3c9274c6acf647726cad5c829839fde8c Mon Sep 17 00:00:00 2001 From: Fernando Dobladez Date: Thu, 18 Mar 2021 16:08:43 -0300 Subject: [PATCH] feat: improve support for relationship queries (nested SELECTs) feat: complete nested FROM with parent-child relationships: SELECT (SELECT Id FROM |) FROM Account feat: complete nested SELECT with fields of the relationship object: SELECT (SELECT | FROM Users) FROM Account feat: complete nested ORDER BY with fields of the relationship object: SELECT (SELECT Id FROM Users ORDER BY |) FROM Account fix: don't propose more than one level of SELECT nesting fix: don't propose aggregate functions or GROUP BY on nested SELECT feat: only propose Id fields on semi-join SELECTs W-8558994 --- package.json | 2 +- src/completion.test.ts | 119 +++++++++------- src/completion.ts | 133 ++++++++++++------ src/completion/SoqlCompletionErrorStrategy.ts | 3 - src/completion/soql-query-analysis.ts | 33 +++-- yarn.lock | 8 +- 6 files changed, 189 insertions(+), 109 deletions(-) diff --git a/package.json b/package.json index 40b967d..f69759a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@salesforce/prettier-config": "^0.0.2", "@types/debounce": "^1.2.0", "@types/jest": "22.2.3", - "@types/vscode": "1.46.0", + "@types/vscode": "1.49.0", "@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/parser": "^4.17.0", "eslint": "^7.21.0", diff --git a/src/completion.test.ts b/src/completion.test.ts index d98dfb6..ba46b90 100644 --- a/src/completion.test.ts +++ b/src/completion.test.ts @@ -168,6 +168,18 @@ const expectedSObjectCompletions: CompletionItem[] = [ }, ]; +function relationshipsItem(sobjectName: string): CompletionItem { + return { + kind: CompletionItemKind.Class, + label: '__RELATIONSHIPS_PLACEHOLDER', + data: { + soqlContext: { + sobjectName, + }, + }, + }; +} + describe('Code Completion on invalid cursor position', () => { it('Should return empty if cursor is on non-exitent line', () => { expect(completionsFor('SELECT id FROM Foo', 2, 5)).toHaveLength(0); @@ -257,33 +269,27 @@ describe('Code Completion on nested select fields: SELECT ... FROM XYZ', () => { validateCompletionsFor('SELECT (SELECT bar FROM Bar),| FROM Foo', sobjectsFieldsFor('Foo')); validateCompletionsFor('SELECT (SELECT bar FROM Bar), | FROM Foo', sobjectsFieldsFor('Foo')); validateCompletionsFor('SELECT id, | (SELECT bar FROM Bar) FROM Foo', sobjectsFieldsFor('Foo')); - validateCompletionsFor('SELECT foo, (SELECT | FROM Bar) FROM Foo', [ - newKeywordItem('COUNT()'), - ...sobjectsFieldsFor('Bar'), - ]); - validateCompletionsFor('SELECT foo, (SELECT |, bar FROM Bar) FROM Foo', [ - newKeywordItem('COUNT()'), - ...sobjectsFieldsFor('Bar'), - ]); - validateCompletionsFor('SELECT foo, (SELECT bar, | FROM Bar) FROM Foo', sobjectsFieldsFor('Bar')); - validateCompletionsFor('SELECT foo, (SELECT bar, (SELECT | FROM XYZ) FROM Bar) FROM Foo', [ - ...newKeywordItems('COUNT()'), - ...sobjectsFieldsFor('XYZ'), - ]); - validateCompletionsFor('SELECT foo, (SELECT |, (SELECT xyz FROM XYZ) FROM Bar) FROM Foo', [ - newKeywordItem('COUNT()'), - ...sobjectsFieldsFor('Bar'), - ]); - validateCompletionsFor('SELECT | (SELECT bar, (SELECT xyz FROM XYZ) FROM Bar) FROM Foo', [ - newKeywordItem('COUNT()'), - ...sobjectsFieldsFor('Foo'), - ]); + validateCompletionsFor('SELECT foo, (SELECT | FROM Bars) FROM Foo', [...relationshipFieldsFor('Foo', 'Bars')]); + + // TODO: improve ANTLR error strategy for this case: + validateCompletionsFor('SELECT foo, (SELECT |, bar FROM Bars) FROM Foo', [...relationshipFieldsFor('Foo', 'Bars')], { + skip: true, + }); + validateCompletionsFor('SELECT foo, (SELECT bar, | FROM Bars) FROM Foo', relationshipFieldsFor('Foo', 'Bars')); - validateCompletionsFor('SELECT (SELECT |) FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); + /* + NOTE: Only 1 level of nesting is allowed. Thus, these are not valid queries: + + SELECT foo, (SELECT bar, (SELECT | FROM XYZ) FROM Bar) FROM Foo + SELECT foo, (SELECT |, (SELECT xyz FROM XYZ) FROM Bar) FROM Foo + SELECT | (SELECT bar, (SELECT xyz FROM XYZ) FROM Bar) FROM Foo + */ + + validateCompletionsFor('SELECT (SELECT |) FROM Foo', relationshipFieldsFor('Foo', undefined)); // We used to have special code just to handle this particular case. // Not worth it, that's why it's skipped now. - // We keep the test here because it'd be nice to solve it in a generic way + // We keep the test here because it'd be nice to solve it in a generic way: validateCompletionsFor('SELECT (SELECT ), | FROM Foo', sobjectsFieldsFor('Foo'), { skip: true }); validateCompletionsFor('SELECT foo, ( | FROM Foo', newKeywordItems('SELECT')); @@ -293,13 +299,12 @@ describe('Code Completion on nested select fields: SELECT ... FROM XYZ', () => { validateCompletionsFor('SELECT foo, (|) FROM Foo', newKeywordItems('SELECT').concat(SELECT_SNIPPET)); - validateCompletionsFor('SELECT foo, (SELECT bar FROM Bar), (SELECT | FROM Xyz) FROM Foo', [ - newKeywordItem('COUNT()'), - ...sobjectsFieldsFor('Xyz'), + validateCompletionsFor('SELECT foo, (SELECT bar FROM Bar), (SELECT | FROM Xyzs) FROM Foo', [ + ...relationshipFieldsFor('Foo', 'Xyzs'), ]); validateCompletionsFor( - 'SELECT foo, (SELECT bar FROM Bar), (SELECT xyz, | FROM Xyz) FROM Foo', - sobjectsFieldsFor('Xyz') + 'SELECT foo, (SELECT bar FROM Bar), (SELECT xyz, | FROM Xyzs) FROM Foo', + relationshipFieldsFor('Foo', 'Xyzs') ); validateCompletionsFor( 'SELECT foo, | (SELECT bar FROM Bar), (SELECT xyz FROM Xyz) FROM Foo', @@ -309,9 +314,8 @@ describe('Code Completion on nested select fields: SELECT ... FROM XYZ', () => { 'SELECT foo, (SELECT bar FROM Bar), | (SELECT xyz FROM Xyz) FROM Foo', sobjectsFieldsFor('Foo') ); - validateCompletionsFor('SELECT foo, (SELECT | FROM Bar), (SELECT xyz FROM Xyz) FROM Foo', [ - newKeywordItem('COUNT()'), - ...sobjectsFieldsFor('Bar'), + validateCompletionsFor('SELECT foo, (SELECT | FROM Bars), (SELECT xyz FROM Xyz) FROM Foo', [ + ...relationshipFieldsFor('Foo', 'Bars'), ]); // With a semi-join (SELECT in WHERE clause): @@ -342,12 +346,18 @@ describe('Code Completion on SELECT XYZ FROM...', () => { validateCompletionsFor('SELECTHHH id FROMXXX |', []); }); -describe('Code Completion on nested SELECT fields FROM', () => { - validateCompletionsFor('SELECT id, (SELECT id FROM |) FROM Foo', expectedSObjectCompletions); +describe('Code Completion on nested SELECT xyz FROM ...: parent-child relationship', () => { + validateCompletionsFor('SELECT id, (SELECT id FROM |) FROM Foo', [relationshipsItem('Foo')]); validateCompletionsFor('SELECT id, (SELECT id FROM Foo) FROM |', expectedSObjectCompletions); + validateCompletionsFor('SELECT id, (SELECT id FROM |), (SELECT id FROM Bar) FROM Foo', [relationshipsItem('Foo')]); + validateCompletionsFor('SELECT id, (SELECT id FROM Foo), (SELECT id FROM |) FROM Bar', [relationshipsItem('Bar')]); validateCompletionsFor( - 'SELECT id, (SELECT FROM |) FROM Foo', // No fields on SELECT - expectedSObjectCompletions + 'SELECT id, (SELECT FROM |) FROM Bar', // No fields on inner SELECT + [relationshipsItem('Bar')] + ); + validateCompletionsFor( + 'SELECT id, (SELECT FROM |), (SELECT Id FROM Foo) FROM Bar', // No fields on SELECT + [relationshipsItem('Bar')] ); }); @@ -375,7 +385,15 @@ describe('Code Completion for ORDER BY', () => { label: '__SOBJECT_FIELDS_PLACEHOLDER', data: { soqlContext: { sobjectName: 'Account', onlySortable: true } }, }, - newKeywordItem('DISTANCE('), + ]); + + // Nested, parent-child relationships: + validateCompletionsFor('SELECT id, (SELECT Email FROM Contacts ORDER BY |) FROM Account', [ + { + kind: CompletionItemKind.Field, + label: '__RELATIONSHIP_FIELDS_PLACEHOLDER', + data: { soqlContext: { sobjectName: 'Account', relationshipName: 'Contacts', onlySortable: true } }, + }, ]); }); @@ -470,9 +488,10 @@ describe('Some keyword candidates after FROM clause', () => { validateCompletionsFor('SELECT id FROM Account WITH |', newKeywordItems('DATA CATEGORY')); + // NOTE: GROUP BY not supported on nested (parent-child relationship) SELECTs validateCompletionsFor('SELECT Account.Name, (SELECT FirstName, LastName FROM Contacts |) FROM Account', [ newKeywordItem('WHERE', { preselect: true }), - ...newKeywordItems('FOR', 'OFFSET', 'LIMIT', 'ORDER BY', 'GROUP BY', 'WITH', 'UPDATE TRACKING', 'UPDATE VIEWSTAT'), + ...newKeywordItems('FOR', 'OFFSET', 'LIMIT', 'ORDER BY', 'WITH', 'UPDATE TRACKING', 'UPDATE VIEWSTAT'), ]); validateCompletionsFor('SELECT id FROM Account LIMIT |', []); @@ -480,7 +499,7 @@ describe('Some keyword candidates after FROM clause', () => { describe('WHERE clause', () => { validateCompletionsFor('SELECT id FROM Account WHERE |', [ - ...newKeywordItems('DISTANCE(', 'NOT'), + ...newKeywordItems('NOT'), { kind: CompletionItemKind.Field, label: '__SOBJECT_FIELDS_PLACEHOLDER', @@ -746,21 +765,16 @@ describe('SELECT Function expressions', () => { }); describe('Code Completion on "semi-join" (SELECT)', () => { + validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT AccountId FROM |)', expectedSObjectCompletions); validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT FROM |)', expectedSObjectCompletions); + // NOTE: The SELECT of a semi-join only accepts an "identifier" type column, no functions validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT | FROM Foo)', [ { kind: CompletionItemKind.Field, label: '__SOBJECT_FIELDS_PLACEHOLDER', - data: { soqlContext: { sobjectName: 'Foo' } }, + data: { soqlContext: { sobjectName: 'Foo', onlyTypes: ['id', 'reference'], dontShowRelationshipField: true } }, }, - newFunctionCallItem('AVG'), - newFunctionCallItem('MIN'), - newFunctionCallItem('MAX'), - newFunctionCallItem('SUM'), - newFunctionCallItem('COUNT'), - newFunctionCallItem('COUNT_DISTINCT'), - INNER_SELECT_SNIPPET, ]); // NOTE: The SELECT of a semi-join can only have one field, thus @@ -834,7 +848,7 @@ function sobjectsFieldsFor(sobjectName: string): CompletionItem[] { label: '__SOBJECT_FIELDS_PLACEHOLDER', data: { soqlContext: { sobjectName } }, }, - ...newKeywordItems('TYPEOF', 'DISTANCE('), + ...newKeywordItems('TYPEOF'), newFunctionCallItem('AVG'), newFunctionCallItem('MIN'), newFunctionCallItem('MAX'), @@ -844,3 +858,14 @@ function sobjectsFieldsFor(sobjectName: string): CompletionItem[] { INNER_SELECT_SNIPPET, ]; } + +function relationshipFieldsFor(sobjectName: string, relationshipName?: string): CompletionItem[] { + return [ + { + kind: CompletionItemKind.Field, + label: '__RELATIONSHIP_FIELDS_PLACEHOLDER', + data: { soqlContext: { sobjectName, relationshipName } }, + }, + ...newKeywordItems('TYPEOF'), + ]; +} diff --git a/src/completion.ts b/src/completion.ts index 0fef082..ddfdbf2 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -5,7 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { SoqlParser, SoqlQueryContext } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParser'; +import { SoqlParser } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParser'; import { SoqlLexer } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlLexer'; import { LowerCasingCharStream } from '@salesforce/soql-common/lib/soql-parser'; import { CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; @@ -26,6 +26,8 @@ import { ParsedSoqlField, SoqlQueryAnalyzer } from './completion/soql-query-anal const SOBJECTS_ITEM_LABEL_PLACEHOLDER = '__SOBJECTS_PLACEHOLDER'; const SOBJECT_FIELDS_LABEL_PLACEHOLDER = '__SOBJECT_FIELDS_PLACEHOLDER'; +const RELATIONSHIPS_PLACEHOLDER = '__RELATIONSHIPS_PLACEHOLDER'; +const RELATIONSHIP_FIELDS_PLACEHOLDER = '__RELATIONSHIP_FIELDS_PLACEHOLDER'; const LITERAL_VALUES_FOR_FIELD = '__LITERAL_VALUES_FOR_FIELD'; const UPDATE_TRACKING = 'UPDATE TRACKING'; const UPDATE_VIEWSTAT = 'UPDATE VIEWSTAT'; @@ -73,7 +75,7 @@ export function completionsFor(text: string, line: number, column: number): Comp const completionItems = itemsFromTokens.concat(itemsFromRules); // If we got no proposals from C3, handle some special cases "manually" - return handleSpecialCases(parsedQuery, tokenStream, completionTokenIndex, completionItems); + return handleSpecialCases(soqlQueryAnalyzer, tokenStream, completionTokenIndex, completionItems); } function collectC3CompletionCandidates( @@ -86,7 +88,7 @@ function collectC3CompletionCandidates( core.ignoredTokens = new Set([ SoqlLexer.BIND, SoqlLexer.LPAREN, - // SoqlLexer.DISTANCE, // Maybe handle it explicitly, as other built-in functions? + SoqlLexer.DISTANCE, // Maybe handle it explicitly, as other built-in functions. Idem for COUNT SoqlLexer.COMMA, SoqlLexer.PLUS, SoqlLexer.MINUS, @@ -197,6 +199,11 @@ function generateCandidatesFromTokens( let itemText = followingKeywords.length > 0 ? baseKeyword + ' ' + followingKeywords : baseKeyword; + // No aggregate features on nested queries + const queryInfos = soqlQueryAnalyzer.queryInfosAt(tokenIndex); + if (queryInfos.length > 1 && (itemText === 'COUNT' || itemText === 'GROUP BY')) { + continue; + } let soqlItemContext: SoqlItemContext | undefined; if (fieldDependentOperators.has(tokenType)) { @@ -215,8 +222,6 @@ function generateCandidatesFromTokens( // Some "manual" improvements for some keywords: if (['IN', 'NOT IN', 'INCLUDES', 'EXCLUDES'].includes(itemText)) { itemText = itemText + ' ('; - } else if (['DISTANCE'].includes(itemText)) { - itemText = itemText + '('; } else if (itemText === 'COUNT') { // NOTE: The g4 grammar declares `COUNT()` explicitly, but not `COUNT(xyz)`. // Here we cover the first case: @@ -254,10 +259,18 @@ function generateCandidatesFromRules( ): CompletionItem[] { const completionItems: CompletionItem[] = []; + const queryInfos = soqlQueryAnalyzer.queryInfosAt(tokenIndex); + const innermostQueryInfo = queryInfos.length > 0 ? queryInfos[0] : undefined; + const fromSObject = innermostQueryInfo?.sobjectName || DEFAULT_SOBJECT; + const soqlItemContext: SoqlItemContext = { + sobjectName: fromSObject, + }; + const isInnerQuery = queryInfos.length > 1; + const relationshipName = isInnerQuery ? queryInfos[0].sobjectName : undefined; + const parentQuerySObject = isInnerQuery ? queryInfos[1].sobjectName : undefined; + for (const [ruleId, ruleData] of c3Rules) { const lastRuleId = ruleData.ruleList[ruleData.ruleList.length - 1]; - let innerQueryInfo; - let fromSObject; switch (ruleId) { case SoqlParser.RULE_soqlUpdateStatsClause: @@ -270,27 +283,45 @@ function generateCandidatesFromRules( break; case SoqlParser.RULE_soqlFromExprs: if (tokenIndex === ruleData.startTokenIndex) { - completionItems.push(newObjectItem(SOBJECTS_ITEM_LABEL_PLACEHOLDER)); + completionItems.push(...itemsForFromExpression(soqlQueryAnalyzer, tokenIndex)); } break; case SoqlParser.RULE_soqlField: - innerQueryInfo = soqlQueryAnalyzer.innerQueryInfoAt(tokenIndex); - fromSObject = innerQueryInfo?.sobjectName || DEFAULT_SOBJECT; - - if ([SoqlParser.RULE_soqlSelectExpr, SoqlParser.RULE_soqlSemiJoin].includes(lastRuleId)) { - // At the start of any "soqlField" expression (inside SELECT, ORDER BY, GROUP BY, etc.) - // or inside a function expression (i.e.: "AVG(|" ) - if ( - tokenIndex === ruleData.startTokenIndex || - isCursorAfter(tokenStream, tokenIndex, [[SoqlLexer.IDENTIFIER, SoqlLexer.COUNT], [SoqlLexer.LPAREN]]) - ) { - const soqlItemContext: SoqlItemContext = { - sobjectName: fromSObject, - }; + if (lastRuleId === SoqlParser.RULE_soqlSemiJoin) { + completionItems.push( + withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { + ...soqlItemContext, + onlyTypes: ['id', 'reference'], + dontShowRelationshipField: true, + }) + ); + } else if (lastRuleId === SoqlParser.RULE_soqlSelectExpr) { + const isCursorAtFunctionExpr: boolean = isCursorAfter(tokenStream, tokenIndex, [ + [SoqlLexer.IDENTIFIER, SoqlLexer.COUNT], + [SoqlLexer.LPAREN], + ]); // inside a function expression (i.e.: "SELECT AVG(|" ) + // SELECT | FROM Xyz + if (tokenIndex === ruleData.startTokenIndex) { + if (isInnerQuery) { + completionItems.push( + withSoqlContext(newFieldItem(RELATIONSHIP_FIELDS_PLACEHOLDER), { + ...soqlItemContext, + sobjectName: parentQuerySObject || '', + relationshipName, + }) + ); + } else { + completionItems.push(withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), soqlItemContext)); + completionItems.push(...itemsForBuiltinFunctions); + completionItems.push(newSnippetItem('(SELECT ... FROM ...)', '(SELECT $2 FROM $1)')); + } + } + // "SELECT AVG(|" + else if (isCursorAtFunctionExpr) { // NOTE: This code would be simpler if the grammar had an explicit - // rule for function invocation. We should probably suggest such a change. + // rule for function invocation. // It's also more complicated because COUNT is a keyword type in the grammar, // and not an IDENTIFIER like all other functions const functionNameToken = searchTokenBeforeCursor(tokenStream, tokenIndex, [ @@ -306,17 +337,11 @@ function generateCandidatesFromRules( } completionItems.push(withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), soqlItemContext)); } - - // SELECT | FROM Xyz - if (tokenIndex === ruleData.startTokenIndex) { - completionItems.push(...itemsForBuiltinFunctions); - completionItems.push(newSnippetItem('(SELECT ... FROM ...)', '(SELECT $2 FROM $1)')); - } } // ... GROUP BY | else if (lastRuleId === SoqlParser.RULE_soqlGroupByExprs && tokenIndex === ruleData.startTokenIndex) { - const selectedFields = innerQueryInfo?.selectedFields || []; - const groupedByFields = (innerQueryInfo?.groupByFields || []).map((f) => f.toLowerCase()); + const selectedFields = innermostQueryInfo?.selectedFields || []; + const groupedByFields = (innermostQueryInfo?.groupByFields || []).map((f) => f.toLowerCase()); const groupFieldDifference = selectedFields.filter((f) => !groupedByFields.includes(f.toLowerCase())); completionItems.push( @@ -324,10 +349,6 @@ function generateCandidatesFromRules( sobjectName: fromSObject, onlyGroupable: true, mostLikelyItems: groupFieldDifference.length > 0 ? groupFieldDifference : undefined, - // joinItemsAsOne: - // groupFieldDifference.length > 0 - // ? groupFieldDifference - // : undefined, }) ); } @@ -335,10 +356,17 @@ function generateCandidatesFromRules( // ... ORDER BY | else if (lastRuleId === SoqlParser.RULE_soqlOrderByClauseField) { completionItems.push( - withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { - sobjectName: fromSObject, - onlySortable: true, - }) + isInnerQuery + ? withSoqlContext(newFieldItem(RELATIONSHIP_FIELDS_PLACEHOLDER), { + ...soqlItemContext, + sobjectName: parentQuerySObject || '', + relationshipName, + onlySortable: true, + }) + : withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { + ...soqlItemContext, + onlySortable: true, + }) ); } @@ -352,8 +380,6 @@ function generateCandidatesFromRules( [SoqlParser.RULE_soqlWhereExpr, SoqlParser.RULE_soqlDistanceExpr].includes(lastRuleId) && !ruleData.ruleList.includes(SoqlParser.RULE_soqlHavingClause) ) { - fromSObject = soqlQueryAnalyzer.innerQueryInfoAt(tokenIndex)?.sobjectName || DEFAULT_SOBJECT; - completionItems.push( withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { sobjectName: fromSObject, @@ -375,7 +401,7 @@ function generateCandidatesFromRules( return completionItems; } function handleSpecialCases( - parsedQuery: SoqlQueryContext, + soqlQueryAnalyzer: SoqlQueryAnalyzer, tokenStream: TokenStream, tokenIndex: number, completionItems: CompletionItem[] @@ -383,7 +409,7 @@ function handleSpecialCases( if (completionItems.length === 0) { // SELECT FROM | if (isCursorAfter(tokenStream, tokenIndex, [[SoqlLexer.SELECT], [SoqlLexer.FROM]])) { - completionItems.push(newObjectItem(SOBJECTS_ITEM_LABEL_PLACEHOLDER)); + completionItems.push(...itemsForFromExpression(soqlQueryAnalyzer, tokenIndex)); } } @@ -396,6 +422,26 @@ function handleSpecialCases( return completionItems; } +function itemsForFromExpression(soqlQueryAnalyzer: SoqlQueryAnalyzer, tokenIndex: number): CompletionItem[] { + const completionItems: CompletionItem[] = []; + const queryInfoStack = soqlQueryAnalyzer.queryInfosAt(tokenIndex); + if (queryInfoStack.length === 1 || (queryInfoStack.length > 1 && queryInfoStack[0].isSemiJoin)) { + completionItems.push(newObjectItem(SOBJECTS_ITEM_LABEL_PLACEHOLDER)); + } else if (queryInfoStack.length > 1) { + const parentQuery = queryInfoStack[1]; + const sobjectName = parentQuery.sobjectName; + if (sobjectName) { + // NOTE: might need to pass multiple outter SObject (nested) names ? + completionItems.push( + withSoqlContext(newObjectItem(RELATIONSHIPS_PLACEHOLDER), { + sobjectName, + }) + ); + } + } + return completionItems; +} + function isCursorAfter(tokenStream: TokenStream, tokenIndex: number, matchingTokens: number[][]): boolean { const toMatch = matchingTokens.concat().reverse(); let matchingIndex = 0; @@ -457,6 +503,7 @@ function newFunctionItem(text: string): CompletionItem { export interface SoqlItemContext { sobjectName: string; + relationshipName?: string; fieldName?: string; onlyTypes?: string[]; onlyAggregatable?: boolean; @@ -464,7 +511,7 @@ export interface SoqlItemContext { onlySortable?: boolean; onlyNillable?: boolean; mostLikelyItems?: string[]; - // joinItemsAsOne?: string[]; + dontShowRelationshipField?: boolean; } function withSoqlContext(item: CompletionItem, soqlItemCtx: SoqlItemContext): CompletionItem { diff --git a/src/completion/SoqlCompletionErrorStrategy.ts b/src/completion/SoqlCompletionErrorStrategy.ts index f28bd87..6d1df21 100644 --- a/src/completion/SoqlCompletionErrorStrategy.ts +++ b/src/completion/SoqlCompletionErrorStrategy.ts @@ -54,16 +54,13 @@ export class SoqlCompletionErrorStrategy extends DefaultErrorStrategy { */ protected getErrorRecoverySet(recognizer: Parser): IntervalSet { const defaultRecoverySet = super.getErrorRecoverySet(recognizer); - if (recognizer.ruleContext.ruleIndex === SoqlParser.RULE_soqlField) { const soqlFieldFollowSet = new IntervalSet(); soqlFieldFollowSet.add(SoqlLexer.COMMA); soqlFieldFollowSet.add(SoqlLexer.FROM); - const intersection = defaultRecoverySet.and(soqlFieldFollowSet); if (intersection.size > 0) return intersection; } - return defaultRecoverySet; } } diff --git a/src/completion/soql-query-analysis.ts b/src/completion/soql-query-analysis.ts index ed71e8c..a4a00e0 100644 --- a/src/completion/soql-query-analysis.ts +++ b/src/completion/soql-query-analysis.ts @@ -24,6 +24,7 @@ interface InnerSoqlQueryInfo { sobjectName?: string; selectedFields?: string[]; groupByFields?: string[]; + isSemiJoin?: boolean; } export interface ParsedSoqlField { @@ -37,12 +38,17 @@ export class SoqlQueryAnalyzer { ParseTreeWalker.DEFAULT.walk(this.innerQueriesListener, parsedQueryTree); } - public innerQueryInfoAt(cursorTokenIndex: number): InnerSoqlQueryInfo | undefined { - return this.innerQueriesListener.findInnerQuery(cursorTokenIndex); + public innermostQueryInfoAt(cursorTokenIndex: number): InnerSoqlQueryInfo | undefined { + const queries = this.queryInfosAt(cursorTokenIndex); + return queries.length > 0 ? queries[0] : undefined; + } + + public queryInfosAt(cursorTokenIndex: number): InnerSoqlQueryInfo[] { + return this.innerQueriesListener.findQueriesAt(cursorTokenIndex); } public extractWhereField(cursorTokenIndex: number): ParsedSoqlField | undefined { - const sobject = this.innerQueryInfoAt(cursorTokenIndex)?.sobjectName; + const sobject = this.innermostQueryInfoAt(cursorTokenIndex)?.sobjectName; if (sobject) { const whereFieldListener = new SoqlWhereFieldListener(cursorTokenIndex, sobject); @@ -58,14 +64,18 @@ export class SoqlQueryAnalyzer { class SoqlInnerQueriesListener implements SoqlParserListener { private innerSoqlQueries = new Map(); - public findInnerQuery(atIndex: number): InnerSoqlQueryInfo | undefined { - let closestQuery: InnerSoqlQueryInfo | undefined; - for (const query of this.innerSoqlQueries.values()) { - if (this.queryContainsTokenIndex(query, atIndex)) { - closestQuery = query; - } - } - return closestQuery; + /** + * Return the list of nested queries which cover the given token position + * + * @param atIndex token index + * @returns the array of queryinfos ordered from the innermost to the outermost + */ + public findQueriesAt(atIndex: number): InnerSoqlQueryInfo[] { + const innerQueries = Array.from(this.innerSoqlQueries.values()).filter((query) => + this.queryContainsTokenIndex(query, atIndex) + ); + const sortedQueries = innerQueries.sort((queryA, queryB) => queryB.select.tokenIndex - queryA.select.tokenIndex); + return sortedQueries; } private queryContainsTokenIndex(innerQuery: InnerSoqlQueryInfo, atTokenIndex: number): boolean { @@ -113,6 +123,7 @@ class SoqlInnerQueriesListener implements SoqlParserListener { public enterSoqlSemiJoin(ctx: SoqlSemiJoinContext): void { this.innerSoqlQueries.set(ctx.start.tokenIndex, { select: ctx.start, + isSemiJoin: true, soqlInnerQueryNode: ctx, }); } diff --git a/yarn.lock b/yarn.lock index 7fdd29f..c932692 100644 --- a/yarn.lock +++ b/yarn.lock @@ -793,10 +793,10 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== -"@types/vscode@1.46.0": - version "1.46.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.46.0.tgz#53f2075986e901ed25cd1ec5f3ffa5db84a111b3" - integrity sha512-8m9wPEB2mcRqTWNKs9A9Eqs8DrQZt0qNFO8GkxBOnyW6xR//3s77SoMgb/nY1ctzACsZXwZj3YRTDsn4bAoaUw== +"@types/vscode@1.49.0": + version "1.49.0" + resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.49.0.tgz#f3731d97d7e8b2697510eb26f6e6d04ee8c17352" + integrity sha512-wfNQmLmm1VdMBr6iuNdprWmC1YdrgZ9dQzadv+l2eSjJlElOdJw8OTm4RU4oGTBcfvG6RZI2jOcppkdSS18mZw== "@types/yargs-parser@*": version "20.2.0"