├── .prettierrc ├── commitlint.config.js ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── feature-request.md │ └── bug-report.md └── workflows │ └── release-please.yml ├── .gitignore ├── .vscode ├── templates │ ├── css.lict │ ├── html.lict │ └── ts.lict ├── tasks.json ├── extensions.json ├── settings.json ├── launch.json └── snippets.code-snippets ├── .eslintrc.json ├── .npmignore ├── jest.config.js ├── SECURITY.md ├── tsconfig.json ├── src ├── index.ts ├── query-validation-feature.ts ├── completion │ ├── SoqlCompletionErrorStrategy.ts │ ├── soql-functions.ts │ └── soql-query-analysis.ts ├── server.ts ├── validator.ts ├── validator.test.ts ├── completion.ts └── completion.test.ts ├── LICENSE.txt ├── LICENSE ├── CHANGELOG.md ├── package.json ├── .circleci └── config.yml ├── CONTRIBUTING.md ├── README.md ├── CODE_OF_CONDUCT.md └── how_to_license.md /.prettierrc: -------------------------------------------------------------------------------- 1 | "@salesforce/prettier-config" 2 | 3 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What does this PR do? 2 | 3 | ### What issues does this PR fix or reference? 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ 4 | Dependencies/package 5 | yarn-error.log 6 | .DS_Store 7 | package-lock.json 8 | test-results/ 9 | 10 | -------------------------------------------------------------------------------- /.vscode/templates/css.lict: -------------------------------------------------------------------------------- 1 | Copyright (c) %(CurrentYear), salesforce.com, inc. 2 | All rights reserved. 3 | Licensed under the BSD 3-Clause license. 4 | For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 5 | -------------------------------------------------------------------------------- /.vscode/templates/html.lict: -------------------------------------------------------------------------------- 1 | Copyright (c) %(CurrentYear), salesforce.com, inc. 2 | All rights reserved. 3 | Licensed under the BSD 3-Clause license. 4 | For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 5 | -------------------------------------------------------------------------------- /.vscode/templates/ts.lict: -------------------------------------------------------------------------------- 1 | Copyright (c) %(CurrentYear), salesforce.com, inc. 2 | All rights reserved. 3 | Licensed under the BSD 3-Clause license. 4 | For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": ["eslint-config-salesforce-typescript"], 5 | "rules": { 6 | "no-shadow": "off", 7 | "@typescript-eslint/no-shadow": "error" 8 | } 9 | } 10 | 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | Dependencies/ 3 | jest.config*js 4 | tsconfig.json 5 | package-lock.json 6 | yarn.lock 7 | .circleci 8 | .github 9 | .vscode 10 | commitlint.config.js 11 | docs/ 12 | CODE_OF_CONDUCT.md 13 | SECURITY.md 14 | 15 | # test files 16 | /lib/**/*.test.d.ts 17 | /lib/**/*.test.js 18 | /lib/**/*.test.js.map 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '\\.(ts)$': 'ts-jest', 5 | }, 6 | testMatch: ['**/*.+(spec|test).(ts|js)'], 7 | preset: 'ts-jest', 8 | testPathIgnorePatterns: ['/lib/', '/node_modules/'], 9 | displayName: 'language-server', 10 | verbose: true, 11 | }; 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "test:unit:debug", 7 | "path": "packages/soql-builder-ui/", 8 | "group": "test", 9 | "label": "lwc-services-debug", 10 | "detail": "lwc-services test:unit --debug", 11 | "isBackground": false 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security 2 | 3 | Please report any security issue to [security@salesforce.com](mailto:security@salesforce.com) 4 | as soon as it is discovered. This library limits its runtime dependencies in 5 | order to reduce the total cost of ownership as much as can be, but all consumers 6 | should remain vigilant and have their security stakeholders review all third-party 7 | products (3PP) like this one and their dependencies. 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "lib": ["es2018"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "outDir": "lib", 9 | "resolveJsonModule": true, 10 | "rootDir": "src", 11 | "sourceMap": true, 12 | "strict": true, 13 | "target": "es2018" 14 | }, 15 | "exclude": ["node_modules"], 16 | "include": ["src"] 17 | } 18 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | 3 | # the repo. Unless a later match takes precedence, 4 | 5 | # @forcedotcom/ide-experience will be requested for 6 | 7 | # review when someone opens a pull request. 8 | 9 | # Order is important; the last matching pattern takes precedence 10 | 11 | # over the first matching pattern. 12 | 13 | #ECCN:Open Source 14 | #GUSINFO: Platform Dev Tools Scrum Team, IDE Experience Team 15 | * @forcedotcom/ide-experience -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | // Git 6 | "eamodio.gitlens", 7 | "donjayamanne.githistory", 8 | // Linter 9 | "dbaeumer.vscode-eslint", 10 | "eg2.tslint", 11 | // Formatting 12 | "esbenp.prettier-vscode", 13 | "stkb.rewrap", 14 | // Testing 15 | "spoonscen.es6-mocha-snippets" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | export { default as QueryValidationFeature } from './query-validation-feature'; 8 | export { SoqlItemContext } from './completion'; 9 | 10 | export const enum RequestTypes { 11 | RunQuery = 'runQuery', 12 | } 13 | 14 | export interface RunQueryResponse { 15 | result?: string; 16 | error?: RunQueryError; 17 | } 18 | 19 | export interface RunQueryError { 20 | name: string; 21 | errorCode: string; 22 | message: string; 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "license-header-manager.excludeExtensions": [ 3 | ".sh", 4 | ".js", 5 | "**.test.**", 6 | "**.spect.**" 7 | ], 8 | "license-header-manager.excludeFolders": [ 9 | "node_modules", 10 | "coverage", 11 | "dist", 12 | "docs", 13 | "scripts", 14 | "lib" 15 | ], 16 | "files.exclude": { 17 | "**/.git": true, 18 | "**/.svn": true, 19 | "**/.hg": true, 20 | "**/CVS": true, 21 | "**/.DS_Store": true, 22 | "**/.vscode-test": true 23 | }, 24 | "search.exclude": { 25 | "**/dist": true, 26 | "**/lib": true 27 | }, 28 | "editor.tabSize": 2, 29 | "rewrap.wrappingColumn": 80, 30 | "editor.formatOnSave": true, 31 | "workbench.colorCustomizations": { 32 | "activityBar.background": "#b45151", 33 | "titleBar.activeBackground": "#b45151", 34 | "titleBar.activeForeground": "#f3f1ef" 35 | }, 36 | "editor.defaultFormatter": "esbenp.prettier-vscode" 37 | } 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 12 | 13 | 16 | 17 | **OS and version**: 18 | 19 | **VS Code Version**: 20 | 21 | **SFDX CLI Version**: 22 | 23 | 24 | ### Summary 25 | 26 | _Short summary of what is going on or to provide context_. 27 | 28 | ### Steps To Reproduce: 29 | 30 | 1. This is step 1. 31 | 1. This is step 2. All steps should start with '1.' 32 | 33 | ### Expected result 34 | 35 | _Describe what should have happened_. 36 | 37 | ### Actual result 38 | 39 | _Describe what actually happened instead_. 40 | 41 | ### Additional information 42 | 43 | _Feel free to attach a screenshot_. 44 | -------------------------------------------------------------------------------- /src/query-validation-feature.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { StaticFeature, ClientCapabilities } from 'vscode-languageclient'; 8 | 9 | export default class QueryValidationFeature implements StaticFeature { 10 | public static hasRunQueryValidation(capabilities: ClientCapabilities): boolean { 11 | const customCapabilities: ClientCapabilities & { 12 | soql?: { runQuery: boolean }; 13 | } = capabilities; 14 | return customCapabilities?.soql?.runQuery || false; 15 | } 16 | 17 | public initialize(): void { 18 | /* do nothing */ 19 | } 20 | 21 | public fillClientCapabilities(capabilities: ClientCapabilities): void { 22 | const customCapabilities: ClientCapabilities & { 23 | soql?: { runQuery: boolean }; 24 | } = capabilities; 25 | customCapabilities.soql = { 26 | ...(customCapabilities.soql || {}), 27 | runQuery: true, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: # <- this allows triggering from github's UI 3 | push: 4 | branches: 5 | - main 6 | name: release-please 7 | jobs: 8 | release-please: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: GoogleCloudPlatform/release-please-action@v2 12 | id: release 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | release-type: node 16 | package-name: soql-language-server 17 | # The logic below handles the npm publication: 18 | - uses: actions/checkout@v2 19 | # these if statements ensure that a publication only occurs when 20 | # a new release is created: 21 | if: ${{ steps.release.outputs.release_created }} 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: 12 25 | registry-url: 'https://registry.npmjs.org' 26 | if: ${{ steps.release.outputs.release_created }} 27 | - run: | 28 | yarn install --frozen-lockfile 29 | yarn build 30 | npm publish 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 33 | if: ${{ steps.release.outputs.release_created }} 34 | 35 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach to Chrome", 9 | "port": 9222, 10 | "request": "attach", 11 | "type": "pwa-chrome", 12 | "webRoot": "${workspaceFolder}" 13 | }, 14 | { 15 | "type": "chrome", 16 | "request": "launch", 17 | "name": "Launch Chrome against localhost", 18 | "url": "http://localhost:3001", 19 | "webRoot": "${workspaceFolder}" 20 | }, 21 | { 22 | "name": "Attach-Debugger", 23 | "type": "node", 24 | "request": "attach", 25 | "port": 9229 26 | }, 27 | { 28 | "name": "Debug Tests - Language Server", 29 | "type": "node", 30 | "request": "launch", 31 | "runtimeArgs": [ 32 | "--inspect-brk", 33 | "${workspaceRoot}/node_modules/.bin/jest", 34 | "--runInBand", 35 | ], 36 | "console": "integratedTerminal", 37 | "internalConsoleOptions": "neverOpen", 38 | "port": 9229 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | // Place your salesforcedx-vscode workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 7 | // Placeholders with the same ids are connected. 8 | // Example: 9 | // "Print to console": { 10 | // "scope": "javascript,typescript", 11 | // "prefix": "log", 12 | // "body": [ 13 | // "console.log('$1');", 14 | // "$2" 15 | // ], 16 | // "description": "Log output to console" 17 | // } 18 | "Insert Copyright Header": { 19 | "prefix": "copyright", 20 | "body": [ 21 | "/*", 22 | " * Copyright (c) 2020, salesforce.com, inc.", 23 | " * All rights reserved.", 24 | " * Licensed under the BSD 3-Clause license.", 25 | " * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause", 26 | " */", 27 | "$0" 28 | ], 29 | "description": "Insert the Salesforce copyright header" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Salesforce, Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of Salesforce nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY SALESFORCE AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL SALESFORCE OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Salesforce Platform 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [0.7.1](https://www.github.com/forcedotcom/soql-language-server/compare/v0.7.0...v0.7.1) (2021-06-09) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * clear diagnostics when file is closed or deleted ([4b7f7be](https://www.github.com/forcedotcom/soql-language-server/commit/4b7f7be957fc2f76274d8d541ffd2013df15ef3b)), closes [#28](https://www.github.com/forcedotcom/soql-language-server/issues/28) 9 | 10 | ## [0.7.0](https://www.github.com/forcedotcom/soql-language-server/compare/v0.6.2...v0.7.0) (2021-04-07) 11 | 12 | 13 | ### Features 14 | 15 | * improve support for relationship queries (nested SELECTs) ([4a973ee](https://www.github.com/forcedotcom/soql-language-server/commit/4a973ee3c9274c6acf647726cad5c829839fde8c)) 16 | 17 | ### [0.6.2](https://www.github.com/forcedotcom/soql-language-server/compare/v0.6.1...v0.6.2) (2021-03-02) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | 23 | ### [0.6.1](https://www.github.com/forcedotcom/soql-language-server/compare/v0.6.0...v0.6.1) (2021-02-25) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * do not publish unnecessary files ([67f437d](https://www.github.com/forcedotcom/soql-language-server/commit/67f437dfead568fe23ea6095a0a775ce2d8fe531)) 29 | 30 | ## [0.6.0](https://www.github.com/forcedotcom/soql-language-server/compare/v0.5.0...v0.6.0) (2021-02-25) 31 | 32 | 33 | ### Features 34 | 35 | * don't bundle soql-parser, depend on soql-common instead ([7511f15](https://www.github.com/forcedotcom/soql-language-server/commit/7511f15e12f924884a7b0fa22ce36440db715f6b)) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * testing auto releasing ([#11](https://www.github.com/forcedotcom/soql-language-server/issues/11)) ([a6c35c0](https://www.github.com/forcedotcom/soql-language-server/commit/a6c35c042b8bc2a1783fa8f98ec6642049e14619)) 41 | 42 | ## [0.5.0](https://www.github.com/forcedotcom/soql-language-server/compare/v0.3.4...v0.5.0) (2021-02-09) 43 | 44 | 45 | ### Other 46 | 47 | * First release from independent repo. No functional changes 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@salesforce/soql-language-server", 3 | "version": "0.7.1", 4 | "description": "SOQL Language Server", 5 | "engines": { 6 | "node": "*" 7 | }, 8 | "main": "lib/index.js", 9 | "scripts": { 10 | "build": "tsc --project .", 11 | "clean": "rimraf lib && rimraf node_modules", 12 | "lint": "eslint src/", 13 | "test": "jest --runInBand", 14 | "test:unit:coverage": "jest --runInBand --coverage" 15 | }, 16 | "dependencies": { 17 | "@salesforce/soql-common": "0.2.1", 18 | "antlr4-c3": "^1.1.13", 19 | "antlr4ts": "^0.5.0-alpha.3", 20 | "debounce": "^1.2.0", 21 | "vscode-languageclient": "6.1.3", 22 | "vscode-languageserver": "6.1.1", 23 | "vscode-languageserver-protocol": "3.15.3", 24 | "vscode-languageserver-textdocument": "1.0.1" 25 | }, 26 | "resolutions": { 27 | "**/vscode-languageserver-protocol": "3.15.3" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.12.3", 31 | "@babel/template": "^7.10.4", 32 | "@commitlint/cli": "^11.0.0", 33 | "@commitlint/config-conventional": "^11.0.0", 34 | "@salesforce/prettier-config": "^0.0.2", 35 | "@types/debounce": "^1.2.0", 36 | "@types/jest": "22.2.3", 37 | "@types/vscode": "1.49.0", 38 | "@typescript-eslint/eslint-plugin": "^4.17.0", 39 | "@typescript-eslint/parser": "^4.17.0", 40 | "eslint": "^7.21.0", 41 | "eslint-config-prettier": "^8.1.0", 42 | "eslint-config-salesforce": "^0.1.0", 43 | "eslint-config-salesforce-typescript": "^0.2.0", 44 | "eslint-plugin-header": "^3.1.1", 45 | "eslint-plugin-import": "^2.22.1", 46 | "eslint-plugin-jsdoc": "^32.3.0", 47 | "eslint-plugin-prettier": "^3.3.1", 48 | "husky": "^4.3.8", 49 | "jest": "26.1.0", 50 | "jest-junit": "^12.0.0", 51 | "prettier": "^2.2.1", 52 | "rimraf": "^3.0.2", 53 | "ts-jest": "26.1.3", 54 | "typescript": "^4.0.3" 55 | }, 56 | "repository": { 57 | "type": "git", 58 | "url": "git+https://github.com/forcedotcom/soql-language-server.git" 59 | }, 60 | "keywords": [ 61 | "soql", 62 | "language-server", 63 | "lsp" 64 | ], 65 | "author": "Salesforce", 66 | "license": "BSD-3-Clause", 67 | "husky": { 68 | "hooks": { 69 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 70 | "pre-push": "yarn run lint" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | slack: circleci/slack@3.4.2 5 | win: circleci/windows@2.4.0 6 | 7 | parameters: 8 | node-version: 9 | type: string 10 | default: '14.15.5' 11 | 12 | variables: 13 | - &node-build-image 14 | - image: circleci/node:12.16.1-stretch 15 | 16 | jobs: 17 | 18 | language-server: 19 | executor: win/default 20 | environment: 21 | NODE_VERSION: << pipeline.parameters.node-version >> 22 | steps: 23 | - checkout 24 | - restore_cache: 25 | key: yarn-cache-{{ checksum "yarn.lock" }} 26 | - run: 27 | name: 'Install node' 28 | shell: bash.exe 29 | command: | 30 | echo 'nvm ls: ' 31 | nvm ls 32 | nvm install $NODE_VERSION 33 | nvm use $NODE_VERSION 34 | - run: 35 | name: 'Install yarn' 36 | shell: bash.exe 37 | command: | 38 | npm install --global yarn 39 | yarn --version 40 | - run: yarn install 41 | - save_cache: 42 | key: yarn-cache-{{ checksum "yarn.lock" }} 43 | paths: 44 | - ./node_modules 45 | - ~/.cache/yarn 46 | - run: 47 | name: Build Packages 48 | shell: bash.exe 49 | command: | 50 | echo 'Building...' 51 | echo 'Node version: ' 52 | node --version 53 | echo 'Node version: ' 54 | yarn --version 55 | yarn run build 56 | - run: 57 | name: 'Run Unit Tests' 58 | shell: bash.exe 59 | command: | 60 | echo 'Run Unit Tests' 61 | mkdir -p ./test-results/junit 62 | JEST_JUNIT_OUTPUT_DIR=./test-results yarn run test:unit:coverage --ci --reporters=default --reporters=jest-junit 63 | - store_test_results: 64 | path: ./test-results 65 | - run: 66 | name: Upload coverage report to Codecov 67 | shell: bash.exe 68 | command: bash <(curl -s https://codecov.io/bash) -t ${CODECOV_TOKEN} 69 | 70 | notify_slack: 71 | docker: *node-build-image 72 | steps: 73 | - slack/notify: 74 | channel: web-tools-bot 75 | title: "Success: ${CIRCLE_USERNAME}'s commit-workflow" 76 | title_link: 'https://circleci.com/workflow-run/${CIRCLE_WORKFLOW_ID}' 77 | color: '#9bcd9b' 78 | message: "${CIRCLE_USERNAME}'s workflow in ${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}\n(${CIRCLE_BRANCH})" 79 | include_project_field: false 80 | include_visit_job_action: false 81 | include_job_number_field: false 82 | 83 | workflows: 84 | version: 2 85 | commit-workflow: 86 | jobs: 87 | - language-server 88 | - notify_slack: 89 | requires: 90 | - language-server 91 | -------------------------------------------------------------------------------- /src/completion/SoqlCompletionErrorStrategy.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { DefaultErrorStrategy } from 'antlr4ts/DefaultErrorStrategy'; 9 | // import { DefaultErrorStrategy } from './DefaultErrorStrategy'; 10 | import { Parser } from 'antlr4ts/Parser'; 11 | import { Token } from 'antlr4ts/Token'; 12 | 13 | import { IntervalSet } from 'antlr4ts/misc/IntervalSet'; 14 | import { SoqlParser } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParser'; 15 | import { SoqlLexer } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlLexer'; 16 | 17 | export class SoqlCompletionErrorStrategy extends DefaultErrorStrategy { 18 | /** 19 | * The default error handling strategy is "too smart" for our code-completion purposes. 20 | * We generally do NOT want the parser to remove tokens for recovery. 21 | * 22 | * @example 23 | * ```soql 24 | * SELECT id, | FROM Foo 25 | * ``` 26 | * Here the default error strategy is drops `FROM` and makes `Foo` a field 27 | * of SELECTs' list. So we don't recognize `Foo` as the SObject we want to 28 | * query for. 29 | * 30 | * We might implement more SOQL-completion-specific logic in the future. 31 | */ 32 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 33 | protected singleTokenDeletion(recognizer: Parser): Token | undefined { 34 | return undefined; 35 | } 36 | 37 | /** 38 | * More aggressive recovering from the parsing of a broken "soqlField": 39 | * keep consuming tokens until we find a COMMA or FROM (iff they are 40 | * part of the tokens recovery set) 41 | * 42 | * This helps with the extraction of the FROM expressions when the SELECT 43 | * expressions do not parse correctly. 44 | * 45 | * @example 46 | * ```soql 47 | * SELECT AVG(|) FROM Account 48 | * ``` 49 | * Here 'AVG()' fails to parse, but the default error strategy doesn't discard 'AVG' 50 | * because it matches the IDENTIFIER token of a following rule (soqlAlias rule). This 51 | * completes the soqlSelectClause and leaves '()' for the soqlFromClause rule, and 52 | * which fails to extract the values off the FROM expressions. 53 | * 54 | */ 55 | protected getErrorRecoverySet(recognizer: Parser): IntervalSet { 56 | const defaultRecoverySet = super.getErrorRecoverySet(recognizer); 57 | if (recognizer.ruleContext.ruleIndex === SoqlParser.RULE_soqlField) { 58 | const soqlFieldFollowSet = new IntervalSet(); 59 | soqlFieldFollowSet.add(SoqlLexer.COMMA); 60 | soqlFieldFollowSet.add(SoqlLexer.FROM); 61 | const intersection = defaultRecoverySet.and(soqlFieldFollowSet); 62 | if (intersection.size > 0) return intersection; 63 | } 64 | return defaultRecoverySet; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 5 | 6 | 1. Create a new issue before starting your project so that we can keep track of 7 | what you are trying to add/fix. That way, we can also offer suggestions or 8 | let you know if there is already an effort in progress. 9 | 1. Fork this repository. 10 | 1. The [README](README.md) has details on how to set up your environment. 11 | 1. Create a _topic_ branch in your fork based on `main`. Note, this step is recommended but technically not required if contributing using a fork. 12 | 1. Edit the code in your fork. 13 | 1. Sign CLA (see [CLA](#cla) below) 14 | 1. Send us a pull request when you are done. We'll review your code, suggest any 15 | needed changes, and merge it in. 16 | 17 | ## Committing 18 | 19 | - We follow [Conventional Commit](https://www.conventionalcommits.org/) messages. The most important prefixes you should have in mind are: 20 | 21 | - fix: which represents bug fixes, and correlates to a SemVer patch. 22 | - feat: which represents a new feature, and correlates to a SemVer minor. 23 | - feat!:, or fix!:, refactor!:, etc., which represent a breaking change (indicated by the !) and will result in a SemVer major. 24 | 25 | - We enforce coding styles using eslint and prettier. Use `yarn lint` to check. 26 | - Before git commit and push, Husky runs hooks to ensure the commit message is in the correct format and that everything lints and compiles properly. 27 | 28 | ## Branches 29 | 30 | - `main` is the only long-lived branch in this repository. It must always be healthy. 31 | - We want to keep the commit history clean and as linear as possible. 32 | - To this end, we integrate topic branches onto `main` using merge commits only of the history of the branch is clean and meaningful and the branch doesn't contain merge commits itself. 33 | - If the branch history is not clean, we squash the changes into a single commit before merging. 34 | - NOTE: It's important to also follow [Conventional Commit](https://www.conventionalcommits.org/) messages for the squash commit!. See [Committing](#Committing) above. 35 | 36 | ## Releases 37 | 38 | The release process and [CHANGELOG](CHANGELOG.md) generation is automated using [release-please](https://github.com/googleapis/release-please), which is triggered from Github actions. 39 | 40 | After every commit that lands on `main`, [release-please](https://github.com/googleapis/release-please) updates (or creates) a release PR on github. This PR includes the version number changes to `package.json` and the updates necessary for the [CHANGELOG](CHANGELOG.md) (which are inferred from the commit messages). 41 | 42 | To perform a release, simply merge the release PR to `main`. After this happens, the automation scripts will create a git tag, publish the packages to NPM and create a release entry on github. 43 | 44 | Before merging the release PR, manual changes can be made on the release branch, including changes to the CHANGELOG. 45 | 46 | ## CLA 47 | 48 | External contributors will be required to sign a Contributor's License Agreement. You can do so by going to https://cla.salesforce.com/sign-cla. 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Table of Contents 2 | 3 | - [Overview](#overview) 4 | - [Installation](#installation) 5 | - [Usage](#usage) 6 | - [Development](#development) 7 | - [Contributing](#contributing) 8 | - [License](#license) 9 | - [Security](#security) 10 | - [Resources](#resources) 11 | 12 | ## Overview 13 | 14 | The SOQL Language Server provides comprehensive language support for SOQL (Salesforce Object Query Language) queries in text editors. This package implements the server-side of the LSP protocol to provide features such as: 15 | 16 | - Code completion and IntelliSense 17 | - Syntax error checking and validation 18 | - Query analysis and optimization suggestions 19 | - Integration with Salesforce metadata 20 | 21 | [Salesforce's SOQL VS Code extension](https://marketplace.visualstudio.com/items?itemName=salesforce.salesforcedx-vscode-soql), which lives in repo [salesforcedx-vscode](https://github.com/forcedotcom/salesforcedx-vscode), includes an LSP client implementation for this server. 22 | 23 | ## Installation 24 | 25 | This package is primarily used as a dependency by the Salesforce SOQL VS Code extension. For end users, the language server is automatically installed when you install the VS Code extension. 26 | 27 | For developers who want to work with the language server directly: 28 | 29 | ```bash 30 | npm install @salesforce/soql-language-server 31 | ``` 32 | 33 | ## Usage 34 | 35 | The language server is designed to work with LSP-compatible editors. It's primarily used through the [Salesforce SOQL VS Code extension](https://marketplace.visualstudio.com/items?itemName=salesforce.salesforcedx-vscode-soql). 36 | 37 | ## Contributing 38 | 39 | If you are interested in contributing, please take a look at the [CONTRIBUTING](CONTRIBUTING.md) guide. 40 | 41 | ## License 42 | 43 | This project is licensed under the BSD 3-Clause License - see the [LICENSE.txt](LICENSE.txt) file for details. 44 | 45 | ## Security 46 | 47 | Please report any security issues to [security@salesforce.com](mailto:security@salesforce.com) as soon as they are discovered. See our [SECURITY.md](SECURITY.md) file for more details. 48 | 49 | ## Development 50 | 51 | - `yarn` from the top-level directory to pull all dependencies 52 | - `yarn build` to build 53 | - `yarn run lint` to run static checks with eslint 54 | - `yarn run test` to run automated tests 55 | 56 | This package is used by VS Code extension `salesforcedx-vscode-soql` which lives in repo [salesforcedx-vscode](https://github.com/forcedotcom/salesforcedx-vscode). 57 | 58 | During development, you can work with a local copy of the `salesforcedx-vscode` repo, and configure it to use your local build from your `soql-language-server` repo using yarn/npm links. Example: 59 | 60 | ``` 61 | # Make global links available 62 | cd soql-language-server 63 | yarn link 64 | 65 | # Link to them from the VS Code SOQL extension package 66 | cd salesforcedx-vscode 67 | npm install 68 | cd ./packages/salesforcedx-vscode-soql 69 | npm link @salesforce/soql-language-server 70 | ``` 71 | 72 | With that in place, you can make changes to `soql-language-server`, build, and then relaunch the `salesforcedx-vscode` extensions from VS Code to see the changes. 73 | 74 | ### Debug Jest Test 75 | 76 | You can debug Jest test for an individual package by running the corresponding launch configuration in VS Codes _RUN_ panel. 77 | 78 | ## Resources 79 | 80 | - Doc: [SOQL and SOSL Reference](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_sosl_intro.htm) 81 | - Doc: [SOQL and SOSL Queries](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/langCon_apex_SOQL.htm) 82 | - Trailhead: [Get Started with SOQL Queries](https://trailhead.salesforce.com/content/learn/modules/soql-for-admins/get-started-with-soql-queries) 83 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { 9 | createConnection, 10 | TextDocuments, 11 | ProposedFeatures, 12 | InitializeParams, 13 | TextDocumentSyncKind, 14 | InitializeResult, 15 | TextDocumentPositionParams, 16 | CompletionItem, 17 | DidChangeWatchedFilesParams, 18 | FileChangeType, 19 | DocumentUri, 20 | } from 'vscode-languageserver'; 21 | import { TextDocument } from 'vscode-languageserver-textdocument'; 22 | import { Validator } from './validator'; 23 | import QueryValidationFeature from './query-validation-feature'; 24 | import { completionsFor } from './completion'; 25 | 26 | // Create a connection for the server, using Node's IPC as a transport. 27 | const connection = createConnection(ProposedFeatures.all); 28 | connection.sendNotification('soql/validate', 'createConnection'); 29 | 30 | let runQueryValidation: boolean; 31 | 32 | // Create a simple text document manager. 33 | const documents: TextDocuments = new TextDocuments(TextDocument); 34 | 35 | connection.onInitialize((params: InitializeParams) => { 36 | runQueryValidation = QueryValidationFeature.hasRunQueryValidation(params.capabilities); 37 | connection.console.log(`runQueryValidation: ${runQueryValidation}`); 38 | const result: InitializeResult = { 39 | capabilities: { 40 | textDocumentSync: TextDocumentSyncKind.Full, // sync full document for now 41 | completionProvider: { 42 | // resolveProvider: true, 43 | triggerCharacters: [' '], 44 | }, 45 | }, 46 | }; 47 | return result; 48 | }); 49 | 50 | function clearDiagnostics(uri: DocumentUri): void { 51 | connection.sendDiagnostics({ uri, diagnostics: [] }); 52 | } 53 | 54 | documents.onDidClose((change) => { 55 | clearDiagnostics(change.document.uri); 56 | }); 57 | 58 | /** 59 | * NOTE: Listening on deleted files should NOT be necessary to trigger the clearing of Diagnostics, 60 | * since the `documents.onDidClose()` callback should take care of it. However, for some reason, 61 | * on automated tests of the SOQL VS Code extension, the 'workbench.action.close*Editor' commands 62 | * don't trigger the `onDidClose()` callback on the language server side. 63 | * 64 | * So, to be safe (and to make tests green) we explicitly clear diagnostics also on deleted files: 65 | */ 66 | connection.onDidChangeWatchedFiles((watchedFiles: DidChangeWatchedFilesParams) => { 67 | const deletedUris = watchedFiles.changes 68 | .filter((change) => change.type === FileChangeType.Deleted) 69 | .map((change) => change.uri); 70 | deletedUris.forEach(clearDiagnostics); 71 | }); 72 | 73 | documents.onDidChangeContent(async (change) => { 74 | const diagnostics = Validator.validateSoqlText(change.document); 75 | // clear syntax errors immediatly (don't wait on http call) 76 | connection.sendDiagnostics({ uri: change.document.uri, diagnostics }); 77 | 78 | if (diagnostics.length === 0 && runQueryValidation) { 79 | const remoteDiagnostics = await Validator.validateLimit0Query(change.document, connection); 80 | if (remoteDiagnostics.length > 0) { 81 | connection.sendDiagnostics({ uri: change.document.uri, diagnostics: remoteDiagnostics }); 82 | } 83 | } 84 | }); 85 | 86 | connection.onCompletion( 87 | // eslint-disable-next-line @typescript-eslint/require-await 88 | async (request: TextDocumentPositionParams): Promise => { 89 | const doc = documents.get(request.textDocument.uri); 90 | if (!doc) return []; 91 | 92 | return completionsFor(doc.getText(), request.position.line + 1, request.position.character + 1); 93 | } 94 | ); 95 | documents.listen(connection); 96 | 97 | connection.listen(); 98 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { SOQLParser } from '@salesforce/soql-common/lib/soql-parser'; 9 | import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver'; 10 | import { TextDocument } from 'vscode-languageserver-textdocument'; 11 | import { Connection } from 'vscode-languageserver'; 12 | import { parseHeaderComments, SoqlWithComments } from '@salesforce/soql-common/lib/soqlComments'; 13 | import { RequestTypes, RunQueryResponse } from './index'; 14 | 15 | const findLimitRegex = new RegExp(/LIMIT\s+\d+\s*$/, 'i'); 16 | const findPositionRegex = new RegExp(/ERROR at Row:(?\d+):Column:(?\d+)/); 17 | const findCauseRegex = new RegExp(/'(?\S+)'/); 18 | 19 | export interface RunQuerySuccessResponse { 20 | done: boolean; 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | records: any[]; 23 | totalSize: number; 24 | } 25 | export interface RunQueryErrorResponse { 26 | name: string; 27 | errorCode: string; 28 | message: string; 29 | } 30 | 31 | export class Validator { 32 | public static validateSoqlText(textDocument: TextDocument): Diagnostic[] { 33 | const diagnostics: Diagnostic[] = []; 34 | const parser = SOQLParser({ 35 | isApex: true, 36 | isMultiCurrencyEnabled: true, 37 | apiVersion: 50.0, 38 | }); 39 | const result = parser.parseQuery(parseHeaderComments(textDocument.getText()).headerPaddedSoqlText); 40 | if (!result.getSuccess()) { 41 | result.getParserErrors().forEach((error) => { 42 | diagnostics.push({ 43 | severity: DiagnosticSeverity.Error, 44 | range: { 45 | start: textDocument.positionAt(error.getToken()?.startIndex as number), 46 | end: textDocument.positionAt(error.getToken()?.stopIndex as number), 47 | }, 48 | message: error.getMessage(), 49 | source: 'soql', 50 | }); 51 | }); 52 | } 53 | return diagnostics; 54 | } 55 | 56 | public static async validateLimit0Query(textDocument: TextDocument, connection: Connection): Promise { 57 | connection.console.log(`validate SOQL query:\n${textDocument.getText()}`); 58 | 59 | const diagnostics: Diagnostic[] = []; 60 | const soqlWithHeaderComments = parseHeaderComments(textDocument.getText()); 61 | 62 | const response = await connection.sendRequest( 63 | RequestTypes.RunQuery, 64 | appendLimit0(soqlWithHeaderComments.soqlText) 65 | ); 66 | 67 | if (response.error) { 68 | const { errorMessage, errorRange } = extractErrorRange(soqlWithHeaderComments, response.error.message); 69 | diagnostics.push({ 70 | severity: DiagnosticSeverity.Error, 71 | range: errorRange || documentRange(textDocument), 72 | message: errorMessage, 73 | source: 'soql', 74 | }); 75 | } 76 | return diagnostics; 77 | } 78 | } 79 | 80 | function appendLimit0(query: string): string { 81 | if (findLimitRegex.test(query)) { 82 | query = query.replace(findLimitRegex, 'LIMIT 0'); 83 | } else { 84 | query = `${query} LIMIT 0`; 85 | } 86 | return query; 87 | } 88 | 89 | function extractErrorRange( 90 | soqlWithComments: SoqlWithComments, 91 | errorMessage: string 92 | ): { errorRange: Range | undefined; errorMessage: string } { 93 | const posMatch = findPositionRegex.exec(errorMessage); 94 | if (posMatch && posMatch.groups) { 95 | const line = Number(posMatch.groups.row) - 1 + soqlWithComments.commentLineCount; 96 | const character = Number(posMatch.groups.column) - 1; 97 | const causeMatch = findCauseRegex.exec(errorMessage); 98 | const cause = (causeMatch && causeMatch.groups && causeMatch.groups.cause) || ' '; 99 | return { 100 | // Strip out the line and column information from the error message 101 | errorMessage: errorMessage.replace(findPositionRegex, 'Error:'), 102 | errorRange: { 103 | start: { line, character }, 104 | end: { line, character: character + cause.length }, 105 | }, 106 | }; 107 | } else { 108 | return { errorMessage, errorRange: undefined }; 109 | } 110 | } 111 | 112 | function documentRange(textDocument: TextDocument): Range { 113 | return { 114 | start: { line: 0, character: 0 }, 115 | end: { line: textDocument.lineCount, character: 0 }, 116 | }; 117 | } 118 | -------------------------------------------------------------------------------- /src/completion/soql-functions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | /** 9 | * Metadata about SOQL built-in functions and operators 10 | * 11 | * Aggregate functions reference: 12 | * https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_agg_functions_field_types.htm 13 | * 14 | * NOTE: The g4 grammar declares `COUNT()` explicitly, but not `COUNT(xyz)`. 15 | * 16 | */ 17 | 18 | interface SOQLFunction { 19 | name: string; 20 | types: string[]; 21 | isAggregate: boolean; 22 | } 23 | 24 | export const soqlFunctions: SOQLFunction[] = [ 25 | { 26 | name: 'AVG', 27 | types: ['double', 'int', 'currency', 'percent'], 28 | isAggregate: true, 29 | }, 30 | { 31 | name: 'COUNT', 32 | types: [ 33 | 'date', 34 | 'datetime', 35 | 'double', 36 | 'int', 37 | 'string', 38 | 'combobox', 39 | 'currency', 40 | 'DataCategoryGroupReference', // ?! 41 | 'email', 42 | 'id', 43 | 'masterrecord', 44 | 'percent', 45 | 'phone', 46 | 'picklist', 47 | 'reference', 48 | 'textarea', 49 | 'url', 50 | ], 51 | isAggregate: true, 52 | }, 53 | { 54 | name: 'COUNT_DISTINCT', 55 | types: [ 56 | 'date', 57 | 'datetime', 58 | 'double', 59 | 'int', 60 | 'string', 61 | 'combobox', 62 | 'currency', 63 | 'DataCategoryGroupReference', // ?! 64 | 'email', 65 | 'id', 66 | 'masterrecord', 67 | 'percent', 68 | 'phone', 69 | 'picklist', 70 | 'reference', 71 | 'textarea', 72 | 'url', 73 | ], 74 | isAggregate: true, 75 | }, 76 | { 77 | name: 'MAX', 78 | types: [ 79 | 'date', 80 | 'datetime', 81 | 'double', 82 | 'int', 83 | 'string', 84 | 'time', 85 | 'combobox', 86 | 'currency', 87 | 'DataCategoryGroupReference', // ?! 88 | 'email', 89 | 'id', 90 | 'masterrecord', 91 | 'percent', 92 | 'phone', 93 | 'picklist', 94 | 'reference', 95 | 'textarea', 96 | 'url', 97 | ], 98 | isAggregate: true, 99 | }, 100 | { 101 | name: 'MIN', 102 | types: [ 103 | 'date', 104 | 'datetime', 105 | 'double', 106 | 'int', 107 | 'string', 108 | 'time', 109 | 'combobox', 110 | 'currency', 111 | 'DataCategoryGroupReference', // ?! 112 | 'email', 113 | 'id', 114 | 'masterrecord', 115 | 'percent', 116 | 'phone', 117 | 'picklist', 118 | 'reference', 119 | 'textarea', 120 | 'url', 121 | ], 122 | isAggregate: true, 123 | }, 124 | { 125 | name: 'SUM', 126 | types: ['int', 'double', 'currency', 'percent'], 127 | isAggregate: true, 128 | }, 129 | ]; 130 | 131 | export const soqlFunctionsByName = soqlFunctions.reduce((result, soqlFn) => { 132 | result[soqlFn.name] = soqlFn; 133 | return result; 134 | }, {} as Record); 135 | 136 | const typesForLTGTOperators = [ 137 | 'anyType', 138 | 'complexvalue', 139 | 'currency', 140 | 'date', 141 | 'datetime', 142 | 'double', 143 | 'int', 144 | 'percent', 145 | 'string', 146 | 'textarea', 147 | 'time', 148 | 'url', 149 | ]; 150 | 151 | // SOQL operators semantics. 152 | // Operators not listed here (i.e. equality operators) are allowed on all types 153 | // and allow nulls 154 | export const soqlOperators: { 155 | [key: string]: { types: string[]; notNullable: boolean }; 156 | } = { 157 | '<': { types: typesForLTGTOperators, notNullable: true }, 158 | '<=': { types: typesForLTGTOperators, notNullable: true }, 159 | '>': { types: typesForLTGTOperators, notNullable: true }, 160 | '>=': { types: typesForLTGTOperators, notNullable: true }, 161 | INCLUDES: { types: ['multipicklist'], notNullable: true }, 162 | EXCLUDES: { types: ['multipicklist'], notNullable: true }, 163 | LIKE: { types: ['string', 'textarea', 'time'], notNullable: true }, 164 | }; 165 | 166 | export const soqlDateRangeLiterals = [ 167 | 'YESTERDAY', 168 | 'TODAY', 169 | 'TOMORROW', 170 | 'LAST_WEEK', 171 | 'THIS_WEEK', 172 | 'NEXT_WEEK', 173 | 'LAST_MONTH', 174 | 'THIS_MONTH', 175 | 'NEXT_MONTH', 176 | 'LAST_90_DAYS', 177 | 'NEXT_90_DAYS', 178 | 'THIS_QUARTER', 179 | 'LAST_QUARTER', 180 | 'NEXT_QUARTER', 181 | 'THIS_YEAR', 182 | 'LAST_YEAR', 183 | 'NEXT_YEAR', 184 | 'THIS_FISCAL_QUARTER', 185 | 'LAST_FISCAL_QUARTER', 186 | 'NEXT_FISCAL_QUARTER', 187 | 'THIS_FISCAL_YEAR', 188 | 'LAST_FISCAL_YEAR', 189 | 'NEXT_FISCAL_YEAR', 190 | ]; 191 | 192 | export const soqlParametricDateRangeLiterals = [ 193 | 'LAST_N_DAYS:n', 194 | 'NEXT_N_DAYS:n', 195 | 'NEXT_N_WEEKS:n', 196 | 'LAST_N_WEEKS:n', 197 | 'NEXT_N_MONTHS:n', 198 | 'LAST_N_MONTHS:n', 199 | 'NEXT_N_QUARTERS:n', 200 | 'LAST_N_QUARTERS:n', 201 | 'NEXT_N_YEARS:n', 202 | 'LAST_N_YEARS:n', 203 | 'NEXT_N_FISCAL_QUARTERS:n', 204 | 'LAST_N_FISCAL_QUARTERS:n', 205 | 'NEXT_N_FISCAL_YEARS:n', 206 | 'LAST_N_FISCAL_YEARS:n', 207 | ]; 208 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Salesforce Open Source Community Code of Conduct 2 | 3 | ## About the Code of Conduct 4 | 5 | Equality is a core value at Salesforce. We believe a diverse and inclusive 6 | community fosters innovation and creativity, and are committed to building a 7 | culture where everyone feels included. 8 | 9 | Salesforce open-source projects are committed to providing a friendly, safe, and 10 | welcoming environment for all, regardless of gender identity and expression, 11 | sexual orientation, disability, physical appearance, body size, ethnicity, nationality, 12 | race, age, religion, level of experience, education, socioeconomic status, or 13 | other similar personal characteristics. 14 | 15 | The goal of this code of conduct is to specify a baseline standard of behavior so 16 | that people with different social values and communication styles can work 17 | together effectively, productively, and respectfully in our open source community. 18 | It also establishes a mechanism for reporting issues and resolving conflicts. 19 | 20 | All questions and reports of abusive, harassing, or otherwise unacceptable behavior 21 | in a Salesforce open-source project may be reported by contacting the Salesforce 22 | Open Source Conduct Committee at ossconduct@salesforce.com. 23 | 24 | ## Our Pledge 25 | 26 | In the interest of fostering an open and welcoming environment, we as 27 | contributors and maintainers pledge to making participation in our project and 28 | our community a harassment-free experience for everyone, regardless of gender 29 | identity and expression, sexual orientation, disability, physical appearance, 30 | body size, ethnicity, nationality, race, age, religion, level of experience, education, 31 | socioeconomic status, or other similar personal characteristics. 32 | 33 | ## Our Standards 34 | 35 | Examples of behavior that contributes to creating a positive environment 36 | include: 37 | 38 | * Using welcoming and inclusive language 39 | * Being respectful of differing viewpoints and experiences 40 | * Gracefully accepting constructive criticism 41 | * Focusing on what is best for the community 42 | * Showing empathy toward other community members 43 | 44 | Examples of unacceptable behavior by participants include: 45 | 46 | * The use of sexualized language or imagery and unwelcome sexual attention or 47 | advances 48 | * Personal attacks, insulting/derogatory comments, or trolling 49 | * Public or private harassment 50 | * Publishing, or threatening to publish, others' private information—such as 51 | a physical or electronic address—without explicit permission 52 | * Other conduct which could reasonably be considered inappropriate in a 53 | professional setting 54 | * Advocating for or encouraging any of the above behaviors 55 | 56 | ## Our Responsibilities 57 | 58 | Project maintainers are responsible for clarifying the standards of acceptable 59 | behavior and are expected to take appropriate and fair corrective action in 60 | response to any instances of unacceptable behavior. 61 | 62 | Project maintainers have the right and responsibility to remove, edit, or 63 | reject comments, commits, code, wiki edits, issues, and other contributions 64 | that are not aligned with this Code of Conduct, or to ban temporarily or 65 | permanently any contributor for other behaviors that they deem inappropriate, 66 | threatening, offensive, or harmful. 67 | 68 | ## Scope 69 | 70 | This Code of Conduct applies both within project spaces and in public spaces 71 | when an individual is representing the project or its community. Examples of 72 | representing a project or community include using an official project email 73 | address, posting via an official social media account, or acting as an appointed 74 | representative at an online or offline event. Representation of a project may be 75 | further defined and clarified by project maintainers. 76 | 77 | ## Enforcement 78 | 79 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 80 | reported by contacting the Salesforce Open Source Conduct Committee 81 | at ossconduct@salesforce.com. All complaints will be reviewed and investigated 82 | and will result in a response that is deemed necessary and appropriate to the 83 | circumstances. The committee is obligated to maintain confidentiality with 84 | regard to the reporter of an incident. Further details of specific enforcement 85 | policies may be posted separately. 86 | 87 | Project maintainers who do not follow or enforce the Code of Conduct in good 88 | faith may face temporary or permanent repercussions as determined by other 89 | members of the project's leadership and the Salesforce Open Source Conduct 90 | Committee. 91 | 92 | ## Attribution 93 | 94 | This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home], 95 | version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. 96 | It includes adaptions and additions from [Go Community Code of Conduct][golang-coc], 97 | [CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc]. 98 | 99 | This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us]. 100 | 101 | [contributor-covenant-home]: https://www.contributor-covenant.org (https://www.contributor-covenant.org/) 102 | [golang-coc]: https://golang.org/conduct 103 | [cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md 104 | [microsoft-coc]: https://opensource.microsoft.com/codeofconduct/ 105 | [cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/ 106 | -------------------------------------------------------------------------------- /how_to_license.md: -------------------------------------------------------------------------------- 1 | How To License Info 2 | ------------------- 3 | 4 | This project uses the [BSD 3-Clause License](https://opensource.org/licenses/BSD-3-Clause). 5 | 6 | For the BSD 3-Clause license, create a `LICENSE.txt` file in the root of your repo containing: 7 | 8 | ``` 9 | BSD 3-Clause License 10 | 11 | Copyright (c) 2025 Salesforce, Inc. 12 | All rights reserved. 13 | 14 | Redistribution and use in source and binary forms, with or without 15 | modification, are permitted provided that the following conditions are met: 16 | 17 | 1. Redistributions of source code must retain the above copyright notice, this 18 | list of conditions and the following disclaimer. 19 | 20 | 2. Redistributions in binary form must reproduce the above copyright notice, 21 | this list of conditions and the following disclaimer in the documentation 22 | and/or other materials provided with the distribution. 23 | 24 | 3. Neither the name of the copyright holder nor the names of its 25 | contributors may be used to endorse or promote products derived from 26 | this software without specific prior written permission. 27 | 28 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 29 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 30 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 31 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 32 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 33 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 34 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 35 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 36 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 37 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 38 | ``` 39 | 40 | The shorter version of license text should be added as a comment to all Salesforce-authored source code and configuration files that support comments. This includes file formats like HTML, CSS, JavaScript, XML, etc. which aren't directly code, but are still critical to your project code. Like: 41 | 42 | ``` 43 | /* 44 | * Copyright (c) 2025, Salesforce, Inc. 45 | * SPDX-License-Identifier: BSD-3-Clause 46 | * 47 | * Redistribution and use in source and binary forms, with or without 48 | * modification, are permitted provided that the following conditions are met: 49 | * 50 | * 1. Redistributions of source code must retain the above copyright notice, this 51 | * list of conditions and the following disclaimer. 52 | * 53 | * 2. Redistributions in binary form must reproduce the above copyright notice, 54 | * this list of conditions and the following disclaimer in the documentation 55 | * and/or other materials provided with the distribution. 56 | * 57 | * 3. Neither the name of the copyright holder nor the names of its 58 | * contributors may be used to endorse or promote products derived from 59 | * this software without specific prior written permission. 60 | * 61 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 62 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 63 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 64 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 65 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 66 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 67 | * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 68 | * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 69 | * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 70 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 71 | */ 72 | ``` 73 | 74 | Note that there are many tools that exist to do this sort of thing in an automated fashion, without having to manually edit every single file in your project. It is highly recommended that you research some of these tools for your particular language / build system. 75 | 76 | For sample, demo, and example code, we recommend the [Unlicense](https://opensource.org/license/unlicense/) license. Create a `LICENSE.txt` file containing: 77 | 78 | ``` 79 | This is free and unencumbered software released into the public domain. 80 | 81 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this 82 | software, either in source code form or as a compiled binary, for any purpose, 83 | commercial or non-commercial, and by any means. 84 | 85 | In jurisdictions that recognize copyright laws, the author or authors of this 86 | software dedicate any and all copyright interest in the software to the public 87 | domain. We make this dedication for the benefit of the public at large and to 88 | the detriment of our heirs and successors. We intend this dedication to be an 89 | overt act of relinquishment in perpetuity of all present and future rights to 90 | this software under copyright law. 91 | 92 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 93 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 94 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 95 | AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 96 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 97 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 98 | ``` 99 | 100 | No license header is required for samples, demos, and example code. -------------------------------------------------------------------------------- /src/validator.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { TextDocument } from 'vscode-languageserver-textdocument'; 9 | import { Connection, RemoteConsole } from 'vscode-languageserver'; 10 | import { Validator, RunQueryErrorResponse, RunQuerySuccessResponse } from './validator'; 11 | 12 | function mockSOQLDoc(content: string): TextDocument { 13 | return TextDocument.create('some-uri', 'soql', 0.1, content); 14 | } 15 | 16 | function createMockClientConnection( 17 | response: { result: RunQuerySuccessResponse } | { error: RunQueryErrorResponse } 18 | ): Connection { 19 | return { 20 | // @ts-expect-error: just for testing 21 | sendRequest: ( 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | method: string, 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/no-explicit-any 25 | params: any 26 | ): { result: RunQuerySuccessResponse } | { error: RunQueryErrorResponse } => response, 27 | // eslint-disable-next-line no-console,@typescript-eslint/no-unsafe-assignment 28 | console: { log: console.log } as RemoteConsole, 29 | }; 30 | } 31 | 32 | describe('Validator', () => { 33 | describe('validateSoqlText', () => { 34 | it('empty diagnostics for a valid SOQL query', () => { 35 | const diagnostics = Validator.validateSoqlText(mockSOQLDoc('SeLeCt Id FrOm Account Ac')); 36 | expect(diagnostics.length).toEqual(0); 37 | }); 38 | it('populated diagnostics for a SOQL query with errors', () => { 39 | const diagnostics = Validator.validateSoqlText(mockSOQLDoc('SeLeCt Id FrOm')); 40 | expect(diagnostics.length).toEqual(1); 41 | }); 42 | }); 43 | 44 | describe('validateLimit0Query', () => { 45 | it('empty diagnostics for a valid SOQL query', async () => { 46 | const diagnostics = await Validator.validateLimit0Query( 47 | mockSOQLDoc('SELECT Id FROM Account'), 48 | createMockClientConnection({ 49 | result: { 50 | done: true, 51 | records: [], 52 | totalSize: 0, 53 | }, 54 | }) 55 | ); 56 | expect(diagnostics.length).toEqual(0); 57 | }); 58 | 59 | it('creates diagnostic with range when location and cause are returned from API', async () => { 60 | const serverError = "Oh Snap!\nERROR at Row:1:Column:8\nBlame 'Ids' not 'Me'"; 61 | const expectedErrorWithoutLineColumn = "Oh Snap!\nError:\nBlame 'Ids' not 'Me'"; 62 | const diagnostics = await Validator.validateLimit0Query( 63 | mockSOQLDoc('SELECT Ids FROM Account'), 64 | createMockClientConnection({ 65 | error: { 66 | name: 'INVALID_FIELD', 67 | errorCode: 'INVALID_FIELD', 68 | message: serverError, 69 | }, 70 | }) 71 | ); 72 | expect(diagnostics).toHaveLength(1); 73 | expect(diagnostics[0].message).toEqual(expectedErrorWithoutLineColumn); 74 | expect(diagnostics[0].range.start.line).toEqual(0); 75 | expect(diagnostics[0].range.start.character).toEqual(7); 76 | expect(diagnostics[0].range.end.line).toEqual(0); 77 | expect(diagnostics[0].range.end.character).toEqual(10); 78 | }); 79 | 80 | it( 81 | 'creates diagnostic with range when location and cause are returned from API' + 82 | ' when query prefixed by newlines', 83 | async () => { 84 | // The Query API seems to be "ignoring" the initial empty lines, so 85 | // it reports the error lines as starting from the first non-empty line 86 | const serverError = "Oh Snap!\nERROR at Row:1:Column:8\nBlame 'Ids' not 'Me'"; 87 | const expectedErrorWithoutLineColumn = "Oh Snap!\nError:\nBlame 'Ids' not 'Me'"; 88 | const diagnostics = await Validator.validateLimit0Query( 89 | mockSOQLDoc('\n\n// Comment here\n\nSELECT Ids FROM Account'), 90 | createMockClientConnection({ 91 | error: { 92 | name: 'INVALID_FIELD', 93 | errorCode: 'INVALID_FIELD', 94 | message: serverError, 95 | }, 96 | }) 97 | ); 98 | 99 | // The expected error line is: the number of empty lines at the top (4), 100 | // plus the reported error line number (1), minus 1 because this is zero based 101 | const errorLine = 4; 102 | 103 | expect(diagnostics).toHaveLength(1); 104 | expect(diagnostics[0].message).toEqual(expectedErrorWithoutLineColumn); 105 | expect(diagnostics[0].range.start.line).toEqual(errorLine); 106 | expect(diagnostics[0].range.start.character).toEqual(7); 107 | expect(diagnostics[0].range.end.line).toEqual(errorLine); 108 | expect(diagnostics[0].range.end.character).toEqual(10); 109 | } 110 | ); 111 | 112 | it('creates diagnostic with full doc range when location is not found', async () => { 113 | const expectedError = 'Oh Snap!'; 114 | const diagnostics = await Validator.validateLimit0Query( 115 | mockSOQLDoc('SELECT Id\nFROM Accounts'), 116 | createMockClientConnection({ 117 | error: { 118 | name: 'INVALID_TYPE', 119 | errorCode: 'INVALID_TYPE', 120 | message: expectedError, 121 | }, 122 | }) 123 | ); 124 | expect(diagnostics).toHaveLength(1); 125 | expect(diagnostics[0].message).toEqual(expectedError); 126 | expect(diagnostics[0].range.start.line).toEqual(0); 127 | expect(diagnostics[0].range.start.character).toEqual(0); 128 | expect(diagnostics[0].range.end.line).toEqual(2); // one line greater than doc length 129 | expect(diagnostics[0].range.end.character).toEqual(0); 130 | }); 131 | 132 | it('creates diagnostic message for errorCode INVALID_TYPE', async () => { 133 | const expectedError = 'Oh Snap!'; 134 | const diagnostics = await Validator.validateLimit0Query( 135 | mockSOQLDoc('SELECT Id\nFROM Accounts'), 136 | createMockClientConnection({ 137 | error: { 138 | name: 'INVALID_TYPE', 139 | errorCode: 'INVALID_TYPE', 140 | message: expectedError, 141 | }, 142 | }) 143 | ); 144 | expect(diagnostics).toHaveLength(1); 145 | expect(diagnostics[0].message).toEqual(expectedError); 146 | expect(diagnostics[0].range.start.line).toEqual(0); 147 | expect(diagnostics[0].range.start.character).toEqual(0); 148 | expect(diagnostics[0].range.end.line).toEqual(2); // one line greater than doc length 149 | expect(diagnostics[0].range.end.character).toEqual(0); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/completion/soql-query-analysis.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { 8 | SoqlFromExprsContext, 9 | SoqlGroupByExprsContext, 10 | SoqlInnerQueryContext, 11 | SoqlParser, 12 | SoqlQueryContext, 13 | SoqlSelectColumnExprContext, 14 | SoqlSemiJoinContext, 15 | } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParser'; 16 | import { ParserRuleContext, Token } from 'antlr4ts'; 17 | import { ParseTreeWalker, RuleNode } from 'antlr4ts/tree'; 18 | import { SoqlParserListener } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParserListener'; 19 | 20 | interface InnerSoqlQueryInfo { 21 | soqlInnerQueryNode: ParserRuleContext; 22 | select: Token; 23 | from?: Token; 24 | sobjectName?: string; 25 | selectedFields?: string[]; 26 | groupByFields?: string[]; 27 | isSemiJoin?: boolean; 28 | } 29 | 30 | export interface ParsedSoqlField { 31 | sobjectName: string; 32 | fieldName: string; 33 | operator?: string; 34 | } 35 | export class SoqlQueryAnalyzer { 36 | private innerQueriesListener = new SoqlInnerQueriesListener(); 37 | public constructor(protected parsedQueryTree: SoqlQueryContext) { 38 | ParseTreeWalker.DEFAULT.walk(this.innerQueriesListener, parsedQueryTree); 39 | } 40 | 41 | public innermostQueryInfoAt(cursorTokenIndex: number): InnerSoqlQueryInfo | undefined { 42 | const queries = this.queryInfosAt(cursorTokenIndex); 43 | return queries.length > 0 ? queries[0] : undefined; 44 | } 45 | 46 | public queryInfosAt(cursorTokenIndex: number): InnerSoqlQueryInfo[] { 47 | return this.innerQueriesListener.findQueriesAt(cursorTokenIndex); 48 | } 49 | 50 | public extractWhereField(cursorTokenIndex: number): ParsedSoqlField | undefined { 51 | const sobject = this.innermostQueryInfoAt(cursorTokenIndex)?.sobjectName; 52 | 53 | if (sobject) { 54 | const whereFieldListener = new SoqlWhereFieldListener(cursorTokenIndex, sobject); 55 | ParseTreeWalker.DEFAULT.walk(whereFieldListener, this.parsedQueryTree); 56 | return whereFieldListener.result; 57 | } else { 58 | return undefined; 59 | } 60 | } 61 | } 62 | 63 | /* eslint-disable @typescript-eslint/member-ordering */ 64 | class SoqlInnerQueriesListener implements SoqlParserListener { 65 | private innerSoqlQueries = new Map(); 66 | 67 | /** 68 | * Return the list of nested queries which cover the given token position 69 | * 70 | * @param atIndex token index 71 | * @returns the array of queryinfos ordered from the innermost to the outermost 72 | */ 73 | public findQueriesAt(atIndex: number): InnerSoqlQueryInfo[] { 74 | const innerQueries = Array.from(this.innerSoqlQueries.values()).filter((query) => 75 | this.queryContainsTokenIndex(query, atIndex) 76 | ); 77 | const sortedQueries = innerQueries.sort((queryA, queryB) => queryB.select.tokenIndex - queryA.select.tokenIndex); 78 | return sortedQueries; 79 | } 80 | 81 | private queryContainsTokenIndex(innerQuery: InnerSoqlQueryInfo, atTokenIndex: number): boolean { 82 | // NOTE: We use the parent node to take into account the enclosing 83 | // parentheses (in the case of inner SELECTs), and the whole text until EOF 84 | // (for the top-level SELECT). BTW: soqlInnerQueryNode always has a parent. 85 | const queryNode = innerQuery.soqlInnerQueryNode.parent 86 | ? innerQuery.soqlInnerQueryNode.parent 87 | : innerQuery.soqlInnerQueryNode; 88 | 89 | const startIndex = queryNode.start.tokenIndex; 90 | const stopIndex = queryNode.stop?.tokenIndex; 91 | 92 | return atTokenIndex > startIndex && !!stopIndex && atTokenIndex <= stopIndex; 93 | } 94 | 95 | private findAncestorSoqlInnerQueryContext(node: RuleNode | undefined): ParserRuleContext | undefined { 96 | let soqlInnerQueryNode = node; 97 | while ( 98 | soqlInnerQueryNode && 99 | ![SoqlParser.RULE_soqlInnerQuery, SoqlParser.RULE_soqlSemiJoin].includes(soqlInnerQueryNode.ruleContext.ruleIndex) 100 | ) { 101 | soqlInnerQueryNode = soqlInnerQueryNode.parent; 102 | } 103 | 104 | return soqlInnerQueryNode ? (soqlInnerQueryNode as ParserRuleContext) : undefined; 105 | } 106 | 107 | private innerQueryForContext(ctx: RuleNode): InnerSoqlQueryInfo | undefined { 108 | const soqlInnerQueryNode = this.findAncestorSoqlInnerQueryContext(ctx); 109 | if (soqlInnerQueryNode) { 110 | const selectFromPair = this.innerSoqlQueries.get(soqlInnerQueryNode.start.tokenIndex); 111 | return selectFromPair; 112 | } 113 | return undefined; 114 | } 115 | 116 | public enterSoqlInnerQuery(ctx: SoqlInnerQueryContext): void { 117 | this.innerSoqlQueries.set(ctx.start.tokenIndex, { 118 | select: ctx.start, 119 | soqlInnerQueryNode: ctx, 120 | }); 121 | } 122 | 123 | public enterSoqlSemiJoin(ctx: SoqlSemiJoinContext): void { 124 | this.innerSoqlQueries.set(ctx.start.tokenIndex, { 125 | select: ctx.start, 126 | isSemiJoin: true, 127 | soqlInnerQueryNode: ctx, 128 | }); 129 | } 130 | 131 | public exitSoqlFromExprs(ctx: SoqlFromExprsContext): void { 132 | const selectFromPair = this.innerQueryForContext(ctx); 133 | 134 | if (ctx.children && ctx.children.length > 0 && selectFromPair) { 135 | const fromToken = ctx.parent?.start as Token; 136 | const sobjectName = ctx.getChild(0).getChild(0).text; 137 | selectFromPair.from = fromToken; 138 | selectFromPair.sobjectName = sobjectName; 139 | } 140 | } 141 | 142 | public enterSoqlSelectColumnExpr(ctx: SoqlSelectColumnExprContext): void { 143 | if (ctx.soqlField().childCount === 1) { 144 | const soqlField = ctx.soqlField(); 145 | const soqlIdentifiers = soqlField.soqlIdentifier(); 146 | if (soqlIdentifiers.length === 1) { 147 | const selectFromPair = this.innerQueryForContext(ctx); 148 | if (selectFromPair) { 149 | if (!selectFromPair.selectedFields) { 150 | selectFromPair.selectedFields = []; 151 | } 152 | selectFromPair.selectedFields.push(soqlIdentifiers[0].text); 153 | } 154 | } 155 | } 156 | } 157 | 158 | public enterSoqlGroupByExprs(ctx: SoqlGroupByExprsContext): void { 159 | const groupByFields: string[] = []; 160 | 161 | ctx.soqlField().forEach((soqlField) => { 162 | const soqlIdentifiers = soqlField.soqlIdentifier(); 163 | if (soqlIdentifiers.length === 1) { 164 | groupByFields.push(soqlIdentifiers[0].text); 165 | } 166 | }); 167 | 168 | if (groupByFields.length > 0) { 169 | const selectFromPair = this.innerQueryForContext(ctx); 170 | 171 | if (selectFromPair) { 172 | selectFromPair.groupByFields = groupByFields; 173 | } 174 | } 175 | } 176 | } 177 | 178 | class SoqlWhereFieldListener implements SoqlParserListener { 179 | private resultDistance = Number.MAX_VALUE; 180 | public result?: ParsedSoqlField; 181 | 182 | public constructor(private readonly cursorTokenIndex: number, private sobject: string) {} 183 | 184 | public enterEveryRule(ctx: ParserRuleContext): void { 185 | if (ctx.ruleContext.ruleIndex === SoqlParser.RULE_soqlWhereExpr) { 186 | if (ctx.start.tokenIndex <= this.cursorTokenIndex) { 187 | const distance = this.cursorTokenIndex - ctx.start.tokenIndex; 188 | if (distance < this.resultDistance) { 189 | this.resultDistance = distance; 190 | const soqlField = ctx.getChild(0).text; 191 | 192 | // Handle basic "dot" expressions 193 | // TODO: Support Aliases 194 | const fieldComponents = soqlField.split('.', 2); 195 | if (fieldComponents[0] === this.sobject) { 196 | fieldComponents.shift(); 197 | } 198 | 199 | const operator = ctx.childCount > 2 ? ctx.getChild(1).text : undefined; 200 | 201 | this.result = { 202 | sobjectName: this.sobject, 203 | fieldName: fieldComponents.join('.'), 204 | operator, 205 | }; 206 | } 207 | } 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/completion.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { SoqlParser } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParser'; 9 | import { SoqlLexer } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlLexer'; 10 | import { LowerCasingCharStream } from '@salesforce/soql-common/lib/soql-parser'; 11 | import { CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; 12 | 13 | import { CommonTokenStream, Parser, ParserRuleContext, Token, TokenStream } from 'antlr4ts'; 14 | 15 | import * as c3 from 'antlr4-c3'; 16 | import { parseHeaderComments } from '@salesforce/soql-common/lib/soqlComments'; 17 | import { 18 | soqlFunctionsByName, 19 | soqlFunctions, 20 | soqlOperators, 21 | soqlDateRangeLiterals, 22 | soqlParametricDateRangeLiterals, 23 | } from './completion/soql-functions'; 24 | import { SoqlCompletionErrorStrategy } from './completion/SoqlCompletionErrorStrategy'; 25 | import { ParsedSoqlField, SoqlQueryAnalyzer } from './completion/soql-query-analysis'; 26 | 27 | const SOBJECTS_ITEM_LABEL_PLACEHOLDER = '__SOBJECTS_PLACEHOLDER'; 28 | const SOBJECT_FIELDS_LABEL_PLACEHOLDER = '__SOBJECT_FIELDS_PLACEHOLDER'; 29 | const RELATIONSHIPS_PLACEHOLDER = '__RELATIONSHIPS_PLACEHOLDER'; 30 | const RELATIONSHIP_FIELDS_PLACEHOLDER = '__RELATIONSHIP_FIELDS_PLACEHOLDER'; 31 | const LITERAL_VALUES_FOR_FIELD = '__LITERAL_VALUES_FOR_FIELD'; 32 | const UPDATE_TRACKING = 'UPDATE TRACKING'; 33 | const UPDATE_VIEWSTAT = 'UPDATE VIEWSTAT'; 34 | const DEFAULT_SOBJECT = 'Object'; 35 | 36 | const itemsForBuiltinFunctions = soqlFunctions.map((soqlFn) => newFunctionItem(soqlFn.name)); 37 | 38 | export function completionsFor(text: string, line: number, column: number): CompletionItem[] { 39 | const lexer = new SoqlLexer(new LowerCasingCharStream(parseHeaderComments(text).headerPaddedSoqlText)); 40 | const tokenStream = new CommonTokenStream(lexer); 41 | const parser = new SoqlParser(tokenStream); 42 | parser.removeErrorListeners(); 43 | parser.errorHandler = new SoqlCompletionErrorStrategy(); 44 | 45 | const parsedQuery = parser.soqlQuery(); 46 | const completionTokenIndex = findCursorTokenIndex(tokenStream, { 47 | line, 48 | column, 49 | }); 50 | 51 | if (completionTokenIndex === undefined) { 52 | // eslint-disable-next-line no-console 53 | console.error("Couldn't find cursor position on toke stream! Lexer might be skipping some tokens!"); 54 | return []; 55 | } 56 | 57 | const c3Candidates = collectC3CompletionCandidates(parser, parsedQuery, completionTokenIndex); 58 | 59 | const soqlQueryAnalyzer = new SoqlQueryAnalyzer(parsedQuery); 60 | 61 | const itemsFromTokens: CompletionItem[] = generateCandidatesFromTokens( 62 | c3Candidates.tokens, 63 | soqlQueryAnalyzer, 64 | lexer, 65 | tokenStream, 66 | completionTokenIndex 67 | ); 68 | const itemsFromRules: CompletionItem[] = generateCandidatesFromRules( 69 | c3Candidates.rules, 70 | soqlQueryAnalyzer, 71 | tokenStream, 72 | completionTokenIndex 73 | ); 74 | 75 | const completionItems = itemsFromTokens.concat(itemsFromRules); 76 | 77 | // If we got no proposals from C3, handle some special cases "manually" 78 | return handleSpecialCases(soqlQueryAnalyzer, tokenStream, completionTokenIndex, completionItems); 79 | } 80 | 81 | function collectC3CompletionCandidates( 82 | parser: Parser, 83 | parsedQuery: ParserRuleContext, 84 | completionTokenIndex: number 85 | ): c3.CandidatesCollection { 86 | const core = new c3.CodeCompletionCore(parser); 87 | core.translateRulesTopDown = false; 88 | core.ignoredTokens = new Set([ 89 | SoqlLexer.BIND, 90 | SoqlLexer.LPAREN, 91 | SoqlLexer.DISTANCE, // Maybe handle it explicitly, as other built-in functions. Idem for COUNT 92 | SoqlLexer.COMMA, 93 | SoqlLexer.PLUS, 94 | SoqlLexer.MINUS, 95 | SoqlLexer.COLON, 96 | SoqlLexer.MINUS, 97 | ]); 98 | 99 | core.preferredRules = new Set([ 100 | SoqlParser.RULE_soqlFromExprs, 101 | SoqlParser.RULE_soqlFromExpr, 102 | SoqlParser.RULE_soqlField, 103 | SoqlParser.RULE_soqlUpdateStatsClause, 104 | SoqlParser.RULE_soqlIdentifier, 105 | SoqlParser.RULE_soqlLiteralValue, 106 | SoqlParser.RULE_soqlLikeLiteral, 107 | ]); 108 | 109 | return core.collectCandidates(completionTokenIndex, parsedQuery); 110 | } 111 | 112 | export function lastX(array: T[]): T | undefined { 113 | return array && array.length > 0 ? array[array.length - 1] : undefined; 114 | } 115 | 116 | const possibleIdentifierPrefix = /[\w]$/; 117 | const lineSeparator = /\n|\r|\r\n/g; 118 | export type CursorPosition = { line: number; column: number }; 119 | 120 | /** 121 | * @returns the token index for which we want to provide completion candidates, 122 | * which depends on the cursor possition. 123 | * 124 | * @example 125 | * ```soql 126 | * SELECT id| FROM x : Cursor touching the previous identifier token: 127 | * we want to continue completing that prior token position 128 | * SELECT id |FROM x : Cursor NOT touching the previous identifier token: 129 | * we want to complete what comes on this new position 130 | * SELECT id | FROM x : Cursor within whitespace block: we want to complete what 131 | * comes after the whitespace (we must return a non-WS token index) 132 | * ``` 133 | */ 134 | export function findCursorTokenIndex(tokenStream: TokenStream, cursor: CursorPosition): number | undefined { 135 | // NOTE: cursor position is 1-based, while token's charPositionInLine is 0-based 136 | const cursorCol = cursor.column - 1; 137 | for (let i = 0; i < tokenStream.size; i++) { 138 | const t = tokenStream.get(i); 139 | 140 | const tokenStartCol = t.charPositionInLine; 141 | const tokenEndCol = tokenStartCol + (t.text as string).length; 142 | const tokenStartLine = t.line; 143 | const tokenEndLine = 144 | t.type !== SoqlLexer.WS || !t.text ? tokenStartLine : tokenStartLine + (t.text.match(lineSeparator)?.length || 0); 145 | 146 | // NOTE: tokenEndCol makes sense only of tokenStartLine === tokenEndLine 147 | if (tokenEndLine > cursor.line || (tokenStartLine === cursor.line && tokenEndCol > cursorCol)) { 148 | if ( 149 | i > 0 && 150 | tokenStartLine === cursor.line && 151 | tokenStartCol === cursorCol && 152 | possibleIdentifierPrefix.test(tokenStream.get(i - 1).text as string) 153 | ) { 154 | return i - 1; 155 | } else if (tokenStream.get(i).type === SoqlLexer.WS) { 156 | return i + 1; 157 | } else return i; 158 | } 159 | } 160 | return undefined; 161 | } 162 | 163 | function tokenTypeToCandidateString(lexer: SoqlLexer, tokenType: number): string { 164 | return lexer.vocabulary.getLiteralName(tokenType)?.toUpperCase().replace(/^'|'$/g, '') as string; 165 | } 166 | 167 | const fieldDependentOperators: Set = new Set([ 168 | SoqlLexer.LT, 169 | SoqlLexer.GT, 170 | SoqlLexer.INCLUDES, 171 | SoqlLexer.EXCLUDES, 172 | SoqlLexer.LIKE, 173 | ]); 174 | 175 | function generateCandidatesFromTokens( 176 | tokens: Map, 177 | soqlQueryAnalyzer: SoqlQueryAnalyzer, 178 | lexer: SoqlLexer, 179 | tokenStream: TokenStream, 180 | tokenIndex: number 181 | ): CompletionItem[] { 182 | const items: CompletionItem[] = []; 183 | for (const [tokenType, followingTokens] of tokens) { 184 | // Don't propose what's already at the cursor position 185 | if (tokenType === tokenStream.get(tokenIndex).type) { 186 | continue; 187 | } 188 | 189 | // Even though the grammar allows spaces between the < > and = signs 190 | // (for example, this is valid: `field < = 'value'`), we don't want to 191 | // propose code completions like that 192 | if (tokenType === SoqlLexer.EQ && isCursorAfter(tokenStream, tokenIndex, [[SoqlLexer.LT, SoqlLexer.GT]])) { 193 | continue; 194 | } 195 | const baseKeyword = tokenTypeToCandidateString(lexer, tokenType); 196 | if (!baseKeyword) continue; 197 | 198 | const followingKeywords = followingTokens.map((t) => tokenTypeToCandidateString(lexer, t)).join(' '); 199 | 200 | let itemText = followingKeywords.length > 0 ? baseKeyword + ' ' + followingKeywords : baseKeyword; 201 | 202 | // No aggregate features on nested queries 203 | const queryInfos = soqlQueryAnalyzer.queryInfosAt(tokenIndex); 204 | if (queryInfos.length > 1 && (itemText === 'COUNT' || itemText === 'GROUP BY')) { 205 | continue; 206 | } 207 | let soqlItemContext: SoqlItemContext | undefined; 208 | 209 | if (fieldDependentOperators.has(tokenType)) { 210 | const soqlFieldExpr = soqlQueryAnalyzer.extractWhereField(tokenIndex); 211 | if (soqlFieldExpr) { 212 | soqlItemContext = { 213 | sobjectName: soqlFieldExpr.sobjectName, 214 | fieldName: soqlFieldExpr.fieldName, 215 | }; 216 | 217 | const soqlOperator = soqlOperators[itemText]; 218 | soqlItemContext.onlyTypes = soqlOperator.types; 219 | } 220 | } 221 | 222 | // Some "manual" improvements for some keywords: 223 | if (['IN', 'NOT IN', 'INCLUDES', 'EXCLUDES'].includes(itemText)) { 224 | itemText = itemText + ' ('; 225 | } else if (itemText === 'COUNT') { 226 | // NOTE: The g4 grammar declares `COUNT()` explicitly, but not `COUNT(xyz)`. 227 | // Here we cover the first case: 228 | itemText = 'COUNT()'; 229 | } 230 | 231 | const newItem = soqlItemContext 232 | ? withSoqlContext(newKeywordItem(itemText), soqlItemContext) 233 | : newKeywordItem(itemText); 234 | 235 | if (itemText === 'WHERE') { 236 | newItem.preselect = true; 237 | } 238 | 239 | items.push(newItem); 240 | 241 | // Clone extra related operators missing by C3 proposals 242 | if (['<', '>'].includes(itemText)) { 243 | items.push({ ...newItem, ...newKeywordItem(itemText + '=') }); 244 | } 245 | if (itemText === '=') { 246 | items.push({ ...newItem, ...newKeywordItem('!=') }); 247 | items.push({ ...newItem, ...newKeywordItem('<>') }); 248 | } 249 | } 250 | return items; 251 | } 252 | 253 | // eslint-disable-next-line complexity 254 | function generateCandidatesFromRules( 255 | c3Rules: Map, 256 | soqlQueryAnalyzer: SoqlQueryAnalyzer, 257 | tokenStream: TokenStream, 258 | tokenIndex: number 259 | ): CompletionItem[] { 260 | const completionItems: CompletionItem[] = []; 261 | 262 | const queryInfos = soqlQueryAnalyzer.queryInfosAt(tokenIndex); 263 | const innermostQueryInfo = queryInfos.length > 0 ? queryInfos[0] : undefined; 264 | const fromSObject = innermostQueryInfo?.sobjectName || DEFAULT_SOBJECT; 265 | const soqlItemContext: SoqlItemContext = { 266 | sobjectName: fromSObject, 267 | }; 268 | const isInnerQuery = queryInfos.length > 1; 269 | const relationshipName = isInnerQuery ? queryInfos[0].sobjectName : undefined; 270 | const parentQuerySObject = isInnerQuery ? queryInfos[1].sobjectName : undefined; 271 | 272 | for (const [ruleId, ruleData] of c3Rules) { 273 | const lastRuleId = ruleData.ruleList[ruleData.ruleList.length - 1]; 274 | 275 | switch (ruleId) { 276 | case SoqlParser.RULE_soqlUpdateStatsClause: 277 | // NOTE: We handle this one as a Rule instead of Tokens because 278 | // "TRACKING" and "VIEWSTAT" are not part of the grammar 279 | if (tokenIndex === ruleData.startTokenIndex) { 280 | completionItems.push(newKeywordItem(UPDATE_TRACKING)); 281 | completionItems.push(newKeywordItem(UPDATE_VIEWSTAT)); 282 | } 283 | break; 284 | case SoqlParser.RULE_soqlFromExprs: 285 | if (tokenIndex === ruleData.startTokenIndex) { 286 | completionItems.push(...itemsForFromExpression(soqlQueryAnalyzer, tokenIndex)); 287 | } 288 | break; 289 | 290 | case SoqlParser.RULE_soqlField: 291 | if (lastRuleId === SoqlParser.RULE_soqlSemiJoin) { 292 | completionItems.push( 293 | withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { 294 | ...soqlItemContext, 295 | onlyTypes: ['id', 'reference'], 296 | dontShowRelationshipField: true, 297 | }) 298 | ); 299 | } else if (lastRuleId === SoqlParser.RULE_soqlSelectExpr) { 300 | const isCursorAtFunctionExpr: boolean = isCursorAfter(tokenStream, tokenIndex, [ 301 | [SoqlLexer.IDENTIFIER, SoqlLexer.COUNT], 302 | [SoqlLexer.LPAREN], 303 | ]); // inside a function expression (i.e.: "SELECT AVG(|" ) 304 | 305 | // SELECT | FROM Xyz 306 | if (tokenIndex === ruleData.startTokenIndex) { 307 | if (isInnerQuery) { 308 | completionItems.push( 309 | withSoqlContext(newFieldItem(RELATIONSHIP_FIELDS_PLACEHOLDER), { 310 | ...soqlItemContext, 311 | sobjectName: parentQuerySObject || '', 312 | relationshipName, 313 | }) 314 | ); 315 | } else { 316 | completionItems.push(withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), soqlItemContext)); 317 | completionItems.push(...itemsForBuiltinFunctions); 318 | completionItems.push(newSnippetItem('(SELECT ... FROM ...)', '(SELECT $2 FROM $1)')); 319 | } 320 | } 321 | // "SELECT AVG(|" 322 | else if (isCursorAtFunctionExpr) { 323 | // NOTE: This code would be simpler if the grammar had an explicit 324 | // rule for function invocation. 325 | // It's also more complicated because COUNT is a keyword type in the grammar, 326 | // and not an IDENTIFIER like all other functions 327 | const functionNameToken = searchTokenBeforeCursor(tokenStream, tokenIndex, [ 328 | SoqlLexer.IDENTIFIER, 329 | SoqlLexer.COUNT, 330 | ]); 331 | if (functionNameToken) { 332 | const soqlFn = soqlFunctionsByName[functionNameToken?.text || '']; 333 | if (soqlFn) { 334 | soqlItemContext.onlyAggregatable = soqlFn.isAggregate; 335 | soqlItemContext.onlyTypes = soqlFn.types; 336 | } 337 | } 338 | completionItems.push(withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), soqlItemContext)); 339 | } 340 | } 341 | // ... GROUP BY | 342 | else if (lastRuleId === SoqlParser.RULE_soqlGroupByExprs && tokenIndex === ruleData.startTokenIndex) { 343 | const selectedFields = innermostQueryInfo?.selectedFields || []; 344 | const groupedByFields = (innermostQueryInfo?.groupByFields || []).map((f) => f.toLowerCase()); 345 | const groupFieldDifference = selectedFields.filter((f) => !groupedByFields.includes(f.toLowerCase())); 346 | 347 | completionItems.push( 348 | withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { 349 | sobjectName: fromSObject, 350 | onlyGroupable: true, 351 | mostLikelyItems: groupFieldDifference.length > 0 ? groupFieldDifference : undefined, 352 | }) 353 | ); 354 | } 355 | 356 | // ... ORDER BY | 357 | else if (lastRuleId === SoqlParser.RULE_soqlOrderByClauseField) { 358 | completionItems.push( 359 | isInnerQuery 360 | ? withSoqlContext(newFieldItem(RELATIONSHIP_FIELDS_PLACEHOLDER), { 361 | ...soqlItemContext, 362 | sobjectName: parentQuerySObject || '', 363 | relationshipName, 364 | onlySortable: true, 365 | }) 366 | : withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { 367 | ...soqlItemContext, 368 | onlySortable: true, 369 | }) 370 | ); 371 | } 372 | 373 | break; 374 | 375 | // For some reason, c3 doesn't propose rule `soqlField` when inside soqlWhereExpr, 376 | // but it does propose soqlIdentifier, so we hinge off it for where expressions 377 | case SoqlParser.RULE_soqlIdentifier: 378 | if ( 379 | tokenIndex === ruleData.startTokenIndex && 380 | [SoqlParser.RULE_soqlWhereExpr, SoqlParser.RULE_soqlDistanceExpr].includes(lastRuleId) && 381 | !ruleData.ruleList.includes(SoqlParser.RULE_soqlHavingClause) 382 | ) { 383 | completionItems.push( 384 | withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { 385 | sobjectName: fromSObject, 386 | }) 387 | ); 388 | } 389 | break; 390 | case SoqlParser.RULE_soqlLiteralValue: 391 | case SoqlParser.RULE_soqlLikeLiteral: 392 | if (!ruleData.ruleList.includes(SoqlParser.RULE_soqlHavingClause)) { 393 | const soqlFieldExpr = soqlQueryAnalyzer.extractWhereField(tokenIndex); 394 | if (soqlFieldExpr) { 395 | for (const literalItem of createItemsForLiterals(soqlFieldExpr)) completionItems.push(literalItem); 396 | } 397 | } 398 | break; 399 | } 400 | } 401 | return completionItems; 402 | } 403 | function handleSpecialCases( 404 | soqlQueryAnalyzer: SoqlQueryAnalyzer, 405 | tokenStream: TokenStream, 406 | tokenIndex: number, 407 | completionItems: CompletionItem[] 408 | ): CompletionItem[] { 409 | if (completionItems.length === 0) { 410 | // SELECT FROM | 411 | if (isCursorAfter(tokenStream, tokenIndex, [[SoqlLexer.SELECT], [SoqlLexer.FROM]])) { 412 | completionItems.push(...itemsForFromExpression(soqlQueryAnalyzer, tokenIndex)); 413 | } 414 | } 415 | 416 | // Provide smart snippet for `SELECT`: 417 | if (completionItems.some((item) => item.label === 'SELECT')) { 418 | if (!isCursorBefore(tokenStream, tokenIndex, [[SoqlLexer.FROM]])) { 419 | completionItems.push(newSnippetItem('SELECT ... FROM ...', 'SELECT $2 FROM $1')); 420 | } 421 | } 422 | return completionItems; 423 | } 424 | 425 | function itemsForFromExpression(soqlQueryAnalyzer: SoqlQueryAnalyzer, tokenIndex: number): CompletionItem[] { 426 | const completionItems: CompletionItem[] = []; 427 | const queryInfoStack = soqlQueryAnalyzer.queryInfosAt(tokenIndex); 428 | if (queryInfoStack.length === 1 || (queryInfoStack.length > 1 && queryInfoStack[0].isSemiJoin)) { 429 | completionItems.push(newObjectItem(SOBJECTS_ITEM_LABEL_PLACEHOLDER)); 430 | } else if (queryInfoStack.length > 1) { 431 | const parentQuery = queryInfoStack[1]; 432 | const sobjectName = parentQuery.sobjectName; 433 | if (sobjectName) { 434 | // NOTE: might need to pass multiple outter SObject (nested) names ? 435 | completionItems.push( 436 | withSoqlContext(newObjectItem(RELATIONSHIPS_PLACEHOLDER), { 437 | sobjectName, 438 | }) 439 | ); 440 | } 441 | } 442 | return completionItems; 443 | } 444 | 445 | function isCursorAfter(tokenStream: TokenStream, tokenIndex: number, matchingTokens: number[][]): boolean { 446 | const toMatch = matchingTokens.concat().reverse(); 447 | let matchingIndex = 0; 448 | 449 | for (let i = tokenIndex - 1; i >= 0; i--) { 450 | const t = tokenStream.get(i); 451 | if (t.channel === SoqlLexer.HIDDEN) continue; 452 | if (toMatch[matchingIndex].includes(t.type)) { 453 | matchingIndex++; 454 | if (matchingIndex === toMatch.length) return true; 455 | } else break; 456 | } 457 | return false; 458 | } 459 | function isCursorBefore(tokenStream: TokenStream, tokenIndex: number, matchingTokens: number[][]): boolean { 460 | const toMatch = matchingTokens.concat(); 461 | let matchingIndex = 0; 462 | 463 | for (let i = tokenIndex; i < tokenStream.size; i++) { 464 | const t = tokenStream.get(i); 465 | if (t.channel === SoqlLexer.HIDDEN) continue; 466 | if (toMatch[matchingIndex].includes(t.type)) { 467 | matchingIndex++; 468 | if (matchingIndex === toMatch.length) return true; 469 | } else break; 470 | } 471 | return false; 472 | } 473 | 474 | function searchTokenBeforeCursor( 475 | tokenStream: TokenStream, 476 | tokenIndex: number, 477 | searchForAnyTokenTypes: number[] 478 | ): Token | undefined { 479 | for (let i = tokenIndex - 1; i >= 0; i--) { 480 | const t = tokenStream.get(i); 481 | if (t.channel === SoqlLexer.HIDDEN) continue; 482 | if (searchForAnyTokenTypes.includes(t.type)) { 483 | return t; 484 | } 485 | } 486 | return undefined; 487 | } 488 | 489 | function newKeywordItem(text: string): CompletionItem { 490 | return { 491 | label: text, 492 | kind: CompletionItemKind.Keyword, 493 | }; 494 | } 495 | function newFunctionItem(text: string): CompletionItem { 496 | return { 497 | label: text + '(...)', 498 | kind: CompletionItemKind.Function, 499 | insertText: text + '($1)', 500 | insertTextFormat: InsertTextFormat.Snippet, 501 | }; 502 | } 503 | 504 | export interface SoqlItemContext { 505 | sobjectName: string; 506 | relationshipName?: string; 507 | fieldName?: string; 508 | onlyTypes?: string[]; 509 | onlyAggregatable?: boolean; 510 | onlyGroupable?: boolean; 511 | onlySortable?: boolean; 512 | onlyNillable?: boolean; 513 | mostLikelyItems?: string[]; 514 | dontShowRelationshipField?: boolean; 515 | } 516 | 517 | function withSoqlContext(item: CompletionItem, soqlItemCtx: SoqlItemContext): CompletionItem { 518 | item.data = { soqlContext: soqlItemCtx }; 519 | return item; 520 | } 521 | 522 | const newCompletionItem = ( 523 | text: string, 524 | kind: CompletionItemKind, 525 | extraOptions?: Partial 526 | ): CompletionItem => ({ 527 | label: text, 528 | kind, 529 | ...extraOptions, 530 | }); 531 | 532 | const newFieldItem = (text: string, extraOptions?: Partial): CompletionItem => 533 | newCompletionItem(text, CompletionItemKind.Field, extraOptions); 534 | 535 | const newConstantItem = (text: string): CompletionItem => newCompletionItem(text, CompletionItemKind.Constant); 536 | 537 | const newObjectItem = (text: string): CompletionItem => newCompletionItem(text, CompletionItemKind.Class); 538 | 539 | const newSnippetItem = (label: string, snippet: string, extraOptions?: Partial): CompletionItem => 540 | newCompletionItem(label, CompletionItemKind.Snippet, { 541 | insertText: snippet, 542 | insertTextFormat: InsertTextFormat.Snippet, 543 | ...extraOptions, 544 | }); 545 | 546 | function createItemsForLiterals(soqlFieldExpr: ParsedSoqlField): CompletionItem[] { 547 | const soqlContext = { 548 | sobjectName: soqlFieldExpr.sobjectName, 549 | fieldName: soqlFieldExpr.fieldName, 550 | }; 551 | 552 | const items: CompletionItem[] = [ 553 | withSoqlContext(newCompletionItem('TRUE', CompletionItemKind.Value), { 554 | ...soqlContext, 555 | ...{ onlyTypes: ['boolean'] }, 556 | }), 557 | withSoqlContext(newCompletionItem('FALSE', CompletionItemKind.Value), { 558 | ...soqlContext, 559 | ...{ onlyTypes: ['boolean'] }, 560 | }), 561 | withSoqlContext(newSnippetItem('nnn', '${1:123}'), { 562 | ...soqlContext, 563 | ...{ onlyTypes: ['int'] }, 564 | }), 565 | withSoqlContext(newSnippetItem('nnn.nnn', '${1:123.456}'), { 566 | ...soqlContext, 567 | ...{ onlyTypes: ['double'] }, 568 | }), 569 | withSoqlContext(newSnippetItem('ISOCODEnnn.nn', '${1|USD,EUR,JPY,CNY,CHF|}${2:999.99}'), { 570 | ...soqlContext, 571 | ...{ onlyTypes: ['currency'] }, 572 | }), 573 | withSoqlContext(newSnippetItem('abc123', "'${1:abc123}'"), { 574 | ...soqlContext, 575 | ...{ onlyTypes: ['string'] }, 576 | }), 577 | withSoqlContext( 578 | newSnippetItem( 579 | 'YYYY-MM-DD', 580 | '${1:${CURRENT_YEAR}}-${2:${CURRENT_MONTH}}-${3:${CURRENT_DATE}}$0', 581 | // extra space prefix on sortText to make it appear first: 582 | { preselect: true, sortText: ' YYYY-MM-DD' } 583 | ), 584 | { ...soqlContext, ...{ onlyTypes: ['date'] } } 585 | ), 586 | withSoqlContext( 587 | newSnippetItem( 588 | 'YYYY-MM-DDThh:mm:ssZ', 589 | '${1:${CURRENT_YEAR}}-${2:${CURRENT_MONTH}}-${3:${CURRENT_DATE}}T${4:${CURRENT_HOUR}}:${5:${CURRENT_MINUTE}}:${6:${CURRENT_SECOND}}Z$0', 590 | // extra space prefix on sortText to make it appear first: 591 | { preselect: true, sortText: ' YYYY-MM-DDThh:mm:ssZ' } 592 | ), 593 | { ...soqlContext, ...{ onlyTypes: ['datetime'] } } 594 | ), 595 | ...soqlDateRangeLiterals.map((k) => 596 | withSoqlContext(newCompletionItem(k, CompletionItemKind.Value), { 597 | ...soqlContext, 598 | ...{ onlyTypes: ['date', 'datetime'] }, 599 | }) 600 | ), 601 | ...soqlParametricDateRangeLiterals.map((k) => 602 | withSoqlContext(newSnippetItem(k, k.replace(':n', ':${1:nn}') + '$0'), { 603 | ...soqlContext, 604 | ...{ onlyTypes: ['date', 'datetime'] }, 605 | }) 606 | ), 607 | 608 | // Give the LSP client a chance to add additional literals: 609 | withSoqlContext(newConstantItem(LITERAL_VALUES_FOR_FIELD), soqlContext), 610 | ]; 611 | 612 | const notNillableOperator = Boolean( 613 | soqlFieldExpr.operator !== undefined && soqlOperators[soqlFieldExpr.operator]?.notNullable 614 | ); 615 | if (!notNillableOperator) { 616 | items.push( 617 | withSoqlContext(newKeywordItem('NULL'), { 618 | ...soqlContext, 619 | ...{ onlyNillable: true }, 620 | }) 621 | ); 622 | } 623 | return items; 624 | } 625 | -------------------------------------------------------------------------------- /src/completion.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; 8 | import { completionsFor, SoqlItemContext } from './completion'; 9 | import { soqlDateRangeLiterals, soqlParametricDateRangeLiterals } from './completion/soql-functions'; 10 | 11 | const SELECT_SNIPPET = { 12 | kind: CompletionItemKind.Snippet, 13 | label: 'SELECT ... FROM ...', 14 | insertText: 'SELECT $2 FROM $1', 15 | insertTextFormat: InsertTextFormat.Snippet, 16 | }; 17 | const INNER_SELECT_SNIPPET = { 18 | kind: CompletionItemKind.Snippet, 19 | label: '(SELECT ... FROM ...)', 20 | insertText: '(SELECT $2 FROM $1)', 21 | insertTextFormat: InsertTextFormat.Snippet, 22 | }; 23 | 24 | const typesForLTGTOperators = [ 25 | 'anyType', 26 | 'complexvalue', 27 | 'currency', 28 | 'date', 29 | 'datetime', 30 | 'double', 31 | 'int', 32 | 'percent', 33 | 'string', 34 | 'textarea', 35 | 'time', 36 | 'url', 37 | ]; 38 | const expectedSoqlContextByKeyword: { 39 | [key: string]: Partial; 40 | } = { 41 | '<': { onlyTypes: typesForLTGTOperators }, 42 | '<=': { onlyTypes: typesForLTGTOperators }, 43 | '>': { onlyTypes: typesForLTGTOperators }, 44 | '>=': { onlyTypes: typesForLTGTOperators }, 45 | 'INCLUDES (': { onlyTypes: ['multipicklist'] }, 46 | 'EXCLUDES (': { onlyTypes: ['multipicklist'] }, 47 | LIKE: { onlyTypes: ['string', 'textarea', 'time'] }, 48 | }; 49 | 50 | function newLiteralItem( 51 | soqlItemContext: SoqlItemContext, 52 | kind: CompletionItemKind, 53 | label: string, 54 | extraOptions: Partial = {} 55 | ): CompletionItem { 56 | return { 57 | label, 58 | kind, 59 | ...extraOptions, 60 | data: { 61 | soqlContext: soqlItemContext, 62 | }, 63 | }; 64 | } 65 | function expectedItemsForLiterals(soqlContext: SoqlItemContext, nillableOperator: boolean): CompletionItem[] { 66 | const items: CompletionItem[] = [ 67 | newLiteralItem(soqlContext, CompletionItemKind.Constant, '__LITERAL_VALUES_FOR_FIELD'), 68 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['boolean'] } }, CompletionItemKind.Value, 'TRUE'), 69 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['boolean'] } }, CompletionItemKind.Value, 'FALSE'), 70 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['int'] } }, CompletionItemKind.Snippet, 'nnn', { 71 | insertText: '${1:123}', 72 | insertTextFormat: InsertTextFormat.Snippet, 73 | }), 74 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['double'] } }, CompletionItemKind.Snippet, 'nnn.nnn', { 75 | insertText: '${1:123.456}', 76 | insertTextFormat: InsertTextFormat.Snippet, 77 | }), 78 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['currency'] } }, CompletionItemKind.Snippet, 'ISOCODEnnn.nn', { 79 | insertText: '${1|USD,EUR,JPY,CNY,CHF|}${2:999.99}', 80 | insertTextFormat: InsertTextFormat.Snippet, 81 | }), 82 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['string'] } }, CompletionItemKind.Snippet, 'abc123', { 83 | insertText: "'${1:abc123}'", 84 | insertTextFormat: InsertTextFormat.Snippet, 85 | }), 86 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['date'] } }, CompletionItemKind.Snippet, 'YYYY-MM-DD', { 87 | insertText: '${1:${CURRENT_YEAR}}-${2:${CURRENT_MONTH}}-${3:${CURRENT_DATE}}$0', 88 | insertTextFormat: InsertTextFormat.Snippet, 89 | preselect: true, 90 | sortText: ' YYYY-MM-DD', 91 | }), 92 | newLiteralItem( 93 | { ...soqlContext, ...{ onlyTypes: ['datetime'] } }, 94 | CompletionItemKind.Snippet, 95 | 'YYYY-MM-DDThh:mm:ssZ', 96 | { 97 | insertText: 98 | '${1:${CURRENT_YEAR}}-${2:${CURRENT_MONTH}}-${3:${CURRENT_DATE}}T${4:${CURRENT_HOUR}}:${5:${CURRENT_MINUTE}}:${6:${CURRENT_SECOND}}Z$0', 99 | insertTextFormat: InsertTextFormat.Snippet, 100 | preselect: true, 101 | sortText: ' YYYY-MM-DDThh:mm:ssZ', 102 | } 103 | ), 104 | ...soqlDateRangeLiterals.map((k) => 105 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['date', 'datetime'] } }, CompletionItemKind.Value, k) 106 | ), 107 | ...soqlParametricDateRangeLiterals.map((k) => 108 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['date', 'datetime'] } }, CompletionItemKind.Snippet, k, { 109 | insertText: k.replace(':n', ':${1:nn}') + '$0', 110 | insertTextFormat: InsertTextFormat.Snippet, 111 | }) 112 | ), 113 | ]; 114 | 115 | if (nillableOperator) { 116 | items.push(newLiteralItem({ ...soqlContext, ...{ onlyNillable: true } }, CompletionItemKind.Keyword, 'NULL')); 117 | } 118 | 119 | return items; 120 | } 121 | 122 | function newKeywordItem(word: string, extraOptions: Partial = {}): CompletionItem { 123 | return Object.assign( 124 | { 125 | kind: CompletionItemKind.Keyword, 126 | label: word, 127 | }, 128 | extraOptions 129 | ); 130 | } 131 | 132 | function newKeywordItems(...words: string[]): CompletionItem[] { 133 | return words.map((s) => ({ 134 | kind: CompletionItemKind.Keyword, 135 | label: s, 136 | })); 137 | } 138 | 139 | function newKeywordItemsWithContext(sobjectName: string, fieldName: string, words: string[]): CompletionItem[] { 140 | return words.map((s) => ({ 141 | kind: CompletionItemKind.Keyword, 142 | label: s, 143 | data: { 144 | soqlContext: { 145 | sobjectName, 146 | fieldName, 147 | ...expectedSoqlContextByKeyword[s], 148 | }, 149 | }, 150 | })); 151 | } 152 | 153 | function newFunctionCallItem(name: string, soqlItemContext?: SoqlItemContext): CompletionItem { 154 | return Object.assign( 155 | { 156 | kind: CompletionItemKind.Function, 157 | label: name + '(...)', 158 | insertText: name + '($1)', 159 | insertTextFormat: InsertTextFormat.Snippet, 160 | }, 161 | soqlItemContext ? { data: { soqlContext: soqlItemContext } } : {} 162 | ); 163 | } 164 | const expectedSObjectCompletions: CompletionItem[] = [ 165 | { 166 | kind: CompletionItemKind.Class, 167 | label: '__SOBJECTS_PLACEHOLDER', 168 | }, 169 | ]; 170 | 171 | function relationshipsItem(sobjectName: string): CompletionItem { 172 | return { 173 | kind: CompletionItemKind.Class, 174 | label: '__RELATIONSHIPS_PLACEHOLDER', 175 | data: { 176 | soqlContext: { 177 | sobjectName, 178 | }, 179 | }, 180 | }; 181 | } 182 | 183 | describe('Code Completion on invalid cursor position', () => { 184 | it('Should return empty if cursor is on non-exitent line', () => { 185 | expect(completionsFor('SELECT id FROM Foo', 2, 5)).toHaveLength(0); 186 | }); 187 | }); 188 | 189 | describe('Code Completion on SELECT ...', () => { 190 | validateCompletionsFor('|', [newKeywordItem('SELECT'), SELECT_SNIPPET]); 191 | validateCompletionsFor('SELE|', [...newKeywordItems('SELECT'), SELECT_SNIPPET]); 192 | validateCompletionsFor('| FROM', newKeywordItems('SELECT')); 193 | validateCompletionsFor('SELECT|', []); 194 | 195 | // "COUNT()" can only be used on its own, unlike "COUNT(fieldName)". 196 | // So we expect it on completions only right after "SELECT" 197 | validateCompletionsFor('SELECT |', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 198 | validateCompletionsFor('SELECT\n|', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 199 | validateCompletionsFor('SELECT\n |', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 200 | validateCompletionsFor('SELECT\n\n |\n\n', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 201 | validateCompletionsFor('SELECT id, |', sobjectsFieldsFor('Object')); 202 | validateCompletionsFor('SELECT id, boo,|', sobjectsFieldsFor('Object')); 203 | validateCompletionsFor('SELECT id|', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 204 | validateCompletionsFor('SELECT id |', newKeywordItems('FROM')); 205 | validateCompletionsFor('SELECT COUNT() |', newKeywordItems('FROM')); 206 | validateCompletionsFor('SELECT COUNT(), |', []); 207 | 208 | // Inside Function expression: 209 | validateCompletionsFor('SELECT OwnerId, COUNT(|)', [ 210 | { 211 | kind: CompletionItemKind.Field, 212 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 213 | data: { 214 | soqlContext: { 215 | sobjectName: 'Object', 216 | onlyAggregatable: true, 217 | onlyTypes: [ 218 | 'date', 219 | 'datetime', 220 | 'double', 221 | 'int', 222 | 'string', 223 | 'combobox', 224 | 'currency', 225 | 'DataCategoryGroupReference', 226 | 'email', 227 | 'id', 228 | 'masterrecord', 229 | 'percent', 230 | 'phone', 231 | 'picklist', 232 | 'reference', 233 | 'textarea', 234 | 'url', 235 | ], 236 | }, 237 | }, 238 | }, 239 | ]); 240 | }); 241 | 242 | describe('Code Completion on select fields: SELECT ... FROM XYZ', () => { 243 | // "COUNT()" can only be used on its own, unlike "COUNT(fieldName)". 244 | // So we expect it on completions only right after "SELECT" 245 | validateCompletionsFor('SELECT | FROM Object', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 246 | validateCompletionsFor('SELECT | FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 247 | validateCompletionsFor('SELECT |FROM Object', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 248 | validateCompletionsFor('SELECT |FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 249 | validateCompletionsFor('SELECT | FROM Foo, Bar', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 250 | validateCompletionsFor('SELECT id, | FROM Foo', sobjectsFieldsFor('Foo')); 251 | validateCompletionsFor('SELECT id,| FROM Foo', sobjectsFieldsFor('Foo')); 252 | validateCompletionsFor('SELECT |, id FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 253 | validateCompletionsFor('SELECT |, id, FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 254 | validateCompletionsFor('SELECT id,| FROM', sobjectsFieldsFor('Object')); 255 | 256 | // with alias 257 | validateCompletionsFor('SELECT id,| FROM Foo F', sobjectsFieldsFor('Foo')); 258 | validateCompletionsFor('SELECT |, id FROM Foo F', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 259 | validateCompletionsFor('SELECT |, id, FROM Foo F', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 260 | }); 261 | 262 | describe('Code Completion on nested select fields: SELECT ... FROM XYZ', () => { 263 | // "COUNT()" can only be used on its own, unlike "COUNT(fieldName)". 264 | // So we expect it on completions only right after "SELECT" 265 | validateCompletionsFor('SELECT | (SELECT bar FROM Bar) FROM Foo', [ 266 | newKeywordItem('COUNT()'), 267 | ...sobjectsFieldsFor('Foo'), 268 | ]); 269 | validateCompletionsFor('SELECT (SELECT bar FROM Bar),| FROM Foo', sobjectsFieldsFor('Foo')); 270 | validateCompletionsFor('SELECT (SELECT bar FROM Bar), | FROM Foo', sobjectsFieldsFor('Foo')); 271 | validateCompletionsFor('SELECT id, | (SELECT bar FROM Bar) FROM Foo', sobjectsFieldsFor('Foo')); 272 | validateCompletionsFor('SELECT foo, (SELECT | FROM Bars) FROM Foo', [...relationshipFieldsFor('Foo', 'Bars')]); 273 | 274 | // TODO: improve ANTLR error strategy for this case: 275 | validateCompletionsFor('SELECT foo, (SELECT |, bar FROM Bars) FROM Foo', [...relationshipFieldsFor('Foo', 'Bars')], { 276 | skip: true, 277 | }); 278 | validateCompletionsFor('SELECT foo, (SELECT bar, | FROM Bars) FROM Foo', relationshipFieldsFor('Foo', 'Bars')); 279 | 280 | /* 281 | NOTE: Only 1 level of nesting is allowed. Thus, these are not valid queries: 282 | 283 | SELECT foo, (SELECT bar, (SELECT | FROM XYZ) FROM Bar) FROM Foo 284 | SELECT foo, (SELECT |, (SELECT xyz FROM XYZ) FROM Bar) FROM Foo 285 | SELECT | (SELECT bar, (SELECT xyz FROM XYZ) FROM Bar) FROM Foo 286 | */ 287 | 288 | validateCompletionsFor('SELECT (SELECT |) FROM Foo', relationshipFieldsFor('Foo', undefined)); 289 | 290 | // We used to have special code just to handle this particular case. 291 | // Not worth it, that's why it's skipped now. 292 | // We keep the test here because it'd be nice to solve it in a generic way: 293 | validateCompletionsFor('SELECT (SELECT ), | FROM Foo', sobjectsFieldsFor('Foo'), { skip: true }); 294 | 295 | validateCompletionsFor('SELECT foo, ( | FROM Foo', newKeywordItems('SELECT')); 296 | validateCompletionsFor('SELECT foo, ( |FROM Foo', newKeywordItems('SELECT')); 297 | validateCompletionsFor('SELECT foo, (| FROM Foo', newKeywordItems('SELECT')); 298 | validateCompletionsFor('SELECT foo, (| FROM Foo', newKeywordItems('SELECT')); 299 | 300 | validateCompletionsFor('SELECT foo, (|) FROM Foo', newKeywordItems('SELECT').concat(SELECT_SNIPPET)); 301 | 302 | validateCompletionsFor('SELECT foo, (SELECT bar FROM Bar), (SELECT | FROM Xyzs) FROM Foo', [ 303 | ...relationshipFieldsFor('Foo', 'Xyzs'), 304 | ]); 305 | validateCompletionsFor( 306 | 'SELECT foo, (SELECT bar FROM Bar), (SELECT xyz, | FROM Xyzs) FROM Foo', 307 | relationshipFieldsFor('Foo', 'Xyzs') 308 | ); 309 | validateCompletionsFor( 310 | 'SELECT foo, | (SELECT bar FROM Bar), (SELECT xyz FROM Xyz) FROM Foo', 311 | sobjectsFieldsFor('Foo') 312 | ); 313 | validateCompletionsFor( 314 | 'SELECT foo, (SELECT bar FROM Bar), | (SELECT xyz FROM Xyz) FROM Foo', 315 | sobjectsFieldsFor('Foo') 316 | ); 317 | validateCompletionsFor('SELECT foo, (SELECT | FROM Bars), (SELECT xyz FROM Xyz) FROM Foo', [ 318 | ...relationshipFieldsFor('Foo', 'Bars'), 319 | ]); 320 | 321 | // With a semi-join (SELECT in WHERE clause): 322 | validateCompletionsFor( 323 | `SELECT Id, Name, | 324 | (SELECT Id, Parent.Profile.Name 325 | FROM SetupEntityAccessItems 326 | WHERE Parent.ProfileId != null) 327 | FROM ApexClass 328 | WHERE Id IN (SELECT SetupEntityId 329 | FROM SetupEntityAccess)`, 330 | sobjectsFieldsFor('ApexClass') 331 | ); 332 | }); 333 | 334 | describe('Code Completion on SELECT XYZ FROM...', () => { 335 | validateCompletionsFor('SELECT id FROM |', expectedSObjectCompletions); 336 | validateCompletionsFor('SELECT id\nFROM |', expectedSObjectCompletions); 337 | 338 | // cursor touching FROM should not complete with Sobject name 339 | validateCompletionsFor('SELECT id\nFROM|', []); 340 | validateCompletionsFor('SELECT id FROM |WHERE', expectedSObjectCompletions); 341 | validateCompletionsFor('SELECT id FROM | WHERE', expectedSObjectCompletions); 342 | validateCompletionsFor('SELECT id FROM | WHERE', expectedSObjectCompletions); 343 | validateCompletionsFor('SELECT id FROM | WHERE', expectedSObjectCompletions); 344 | validateCompletionsFor('SELECT id \nFROM |\nWHERE', expectedSObjectCompletions); 345 | 346 | validateCompletionsFor('SELECTHHH id FROMXXX |', []); 347 | }); 348 | 349 | describe('Code Completion on nested SELECT xyz FROM ...: parent-child relationship', () => { 350 | validateCompletionsFor('SELECT id, (SELECT id FROM |) FROM Foo', [relationshipsItem('Foo')]); 351 | validateCompletionsFor('SELECT id, (SELECT id FROM Foo) FROM |', expectedSObjectCompletions); 352 | validateCompletionsFor('SELECT id, (SELECT id FROM |), (SELECT id FROM Bar) FROM Foo', [relationshipsItem('Foo')]); 353 | validateCompletionsFor('SELECT id, (SELECT id FROM Foo), (SELECT id FROM |) FROM Bar', [relationshipsItem('Bar')]); 354 | validateCompletionsFor( 355 | 'SELECT id, (SELECT FROM |) FROM Bar', // No fields on inner SELECT 356 | [relationshipsItem('Bar')] 357 | ); 358 | validateCompletionsFor( 359 | 'SELECT id, (SELECT FROM |), (SELECT Id FROM Foo) FROM Bar', // No fields on SELECT 360 | [relationshipsItem('Bar')] 361 | ); 362 | }); 363 | 364 | describe('Code Completion on SELECT FROM (no columns on SELECT)', () => { 365 | validateCompletionsFor('SELECT FROM |', expectedSObjectCompletions, {}); 366 | validateCompletionsFor('SELECT\nFROM |', expectedSObjectCompletions); 367 | 368 | validateCompletionsFor('SELECT FROM | WHERE', expectedSObjectCompletions); 369 | validateCompletionsFor('SELECT\nFROM |\nWHERE\nORDER BY', expectedSObjectCompletions); 370 | 371 | describe('Cursor is still touching FROM: it should still complete with fieldnames, and not SObject names', () => { 372 | validateCompletionsFor('SELECT FROM|', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 373 | 374 | validateCompletionsFor('SELECT\nFROM|', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 375 | validateCompletionsFor('SELECT\nFROM|\nWHERE', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 376 | }); 377 | 378 | validateCompletionsFor('SELECTHHH FROMXXX |', []); 379 | }); 380 | 381 | describe('Code Completion for ORDER BY', () => { 382 | validateCompletionsFor('SELECT id FROM Account ORDER BY |', [ 383 | { 384 | kind: CompletionItemKind.Field, 385 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 386 | data: { soqlContext: { sobjectName: 'Account', onlySortable: true } }, 387 | }, 388 | ]); 389 | 390 | // Nested, parent-child relationships: 391 | validateCompletionsFor('SELECT id, (SELECT Email FROM Contacts ORDER BY |) FROM Account', [ 392 | { 393 | kind: CompletionItemKind.Field, 394 | label: '__RELATIONSHIP_FIELDS_PLACEHOLDER', 395 | data: { soqlContext: { sobjectName: 'Account', relationshipName: 'Contacts', onlySortable: true } }, 396 | }, 397 | ]); 398 | }); 399 | 400 | describe('Code Completion for GROUP BY', () => { 401 | validateCompletionsFor('SELECT COUNT(Id) FROM Account GROUP BY |', [ 402 | { 403 | kind: CompletionItemKind.Field, 404 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 405 | data: { soqlContext: { sobjectName: 'Account', onlyGroupable: true } }, 406 | }, 407 | ...newKeywordItems('ROLLUP', 'CUBE'), 408 | ]); 409 | 410 | validateCompletionsFor('SELECT id FROM Account GROUP BY id |', [ 411 | ...newKeywordItems('FOR', 'OFFSET', 'HAVING', 'LIMIT', 'ORDER BY', 'UPDATE TRACKING', 'UPDATE VIEWSTAT'), 412 | ]); 413 | 414 | // When there are aggregated fields on SELECT, the GROUP BY clause 415 | // must include all non-aggregated fields... thus we want completion 416 | // for those preselected 417 | validateCompletionsFor('SELECT id FROM Account GROUP BY |', [ 418 | { 419 | kind: CompletionItemKind.Field, 420 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 421 | data: { 422 | soqlContext: { 423 | sobjectName: 'Account', 424 | onlyGroupable: true, 425 | mostLikelyItems: ['id'], 426 | }, 427 | }, 428 | }, 429 | ...newKeywordItems('ROLLUP', 'CUBE'), 430 | ]); 431 | validateCompletionsFor('SELECT id, MAX(id2), AVG(AnnualRevenue) FROM Account GROUP BY |', [ 432 | { 433 | kind: CompletionItemKind.Field, 434 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 435 | data: { 436 | soqlContext: { 437 | sobjectName: 'Account', 438 | onlyGroupable: true, 439 | mostLikelyItems: ['id'], 440 | }, 441 | }, 442 | }, 443 | ...newKeywordItems('ROLLUP', 'CUBE'), 444 | ]); 445 | 446 | validateCompletionsFor('SELECT ID, Name, MAX(id3), AVG(AnnualRevenue) FROM Account GROUP BY id, |', [ 447 | { 448 | kind: CompletionItemKind.Field, 449 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 450 | data: { 451 | soqlContext: { 452 | sobjectName: 'Account', 453 | onlyGroupable: true, 454 | mostLikelyItems: ['Name'], 455 | }, 456 | }, 457 | }, 458 | // NOTE: ROLLUP and CUBE not expected unless cursor right after GROUP BY 459 | ]); 460 | 461 | // Expect more than one. Also test with inner queries.. 462 | validateCompletionsFor( 463 | 'SELECT Id, Name, (SELECT Id, Id2, AboutMe FROM User), AVG(AnnualRevenue) FROM Account GROUP BY |', 464 | [ 465 | { 466 | kind: CompletionItemKind.Field, 467 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 468 | data: { 469 | soqlContext: { 470 | sobjectName: 'Account', 471 | onlyGroupable: true, 472 | mostLikelyItems: ['Id', 'Name'], 473 | }, 474 | }, 475 | }, 476 | ...newKeywordItems('ROLLUP', 'CUBE'), 477 | ] 478 | ); 479 | }); 480 | 481 | describe('Some keyword candidates after FROM clause', () => { 482 | validateCompletionsFor('SELECT id FROM Account |', [ 483 | newKeywordItem('WHERE', { preselect: true }), 484 | ...newKeywordItems('FOR', 'OFFSET', 'LIMIT', 'ORDER BY', 'GROUP BY', 'WITH', 'UPDATE TRACKING', 'UPDATE VIEWSTAT'), 485 | ]); 486 | 487 | validateCompletionsFor('SELECT id FROM Account FOR |', newKeywordItems('VIEW', 'REFERENCE')); 488 | 489 | validateCompletionsFor('SELECT id FROM Account WITH |', newKeywordItems('DATA CATEGORY')); 490 | 491 | // NOTE: GROUP BY not supported on nested (parent-child relationship) SELECTs 492 | validateCompletionsFor('SELECT Account.Name, (SELECT FirstName, LastName FROM Contacts |) FROM Account', [ 493 | newKeywordItem('WHERE', { preselect: true }), 494 | ...newKeywordItems('FOR', 'OFFSET', 'LIMIT', 'ORDER BY', 'WITH', 'UPDATE TRACKING', 'UPDATE VIEWSTAT'), 495 | ]); 496 | 497 | validateCompletionsFor('SELECT id FROM Account LIMIT |', []); 498 | }); 499 | 500 | describe('WHERE clause', () => { 501 | validateCompletionsFor('SELECT id FROM Account WHERE |', [ 502 | ...newKeywordItems('NOT'), 503 | { 504 | kind: CompletionItemKind.Field, 505 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 506 | data: { soqlContext: { sobjectName: 'Account' } }, 507 | }, 508 | ]); 509 | validateCompletionsFor('SELECT id FROM Account WHERE Name |', [ 510 | ...newKeywordItems('IN (', 'NOT IN (', '=', '!=', '<>'), 511 | ...newKeywordItemsWithContext('Account', 'Name', ['INCLUDES (', 'EXCLUDES (', '<', '<=', '>', '>=', 'LIKE']), 512 | ]); 513 | 514 | validateCompletionsFor('SELECT id FROM Account WHERE Type IN (|', [ 515 | ...newKeywordItems('SELECT'), 516 | SELECT_SNIPPET, 517 | ...expectedItemsForLiterals( 518 | { 519 | sobjectName: 'Account', 520 | fieldName: 'Type', 521 | }, 522 | true 523 | ), 524 | ]); 525 | 526 | validateCompletionsFor( 527 | "SELECT id FROM Account WHERE Type IN ('Customer', |)", 528 | expectedItemsForLiterals( 529 | { 530 | sobjectName: 'Account', 531 | fieldName: 'Type', 532 | }, 533 | true 534 | ) 535 | ); 536 | validateCompletionsFor("SELECT id FROM Account WHERE Type IN (|, 'Customer')", [ 537 | ...newKeywordItems('SELECT'), 538 | SELECT_SNIPPET, 539 | ...expectedItemsForLiterals( 540 | { 541 | sobjectName: 'Account', 542 | fieldName: 'Type', 543 | }, 544 | true 545 | ), 546 | ]); 547 | 548 | // NOTE: Unlike IN(), INCLUDES()/EXCLUDES() never support NULL in the list 549 | validateCompletionsFor( 550 | 'SELECT Channel FROM QuickText WHERE Channel INCLUDES (|', 551 | expectedItemsForLiterals( 552 | { 553 | sobjectName: 'QuickText', 554 | fieldName: 'Channel', 555 | }, 556 | false 557 | ) 558 | ); 559 | 560 | validateCompletionsFor( 561 | "SELECT Channel FROM QuickText WHERE Channel EXCLUDES('Email', |", 562 | expectedItemsForLiterals( 563 | { 564 | sobjectName: 'QuickText', 565 | fieldName: 'Channel', 566 | }, 567 | false 568 | ) 569 | ); 570 | validateCompletionsFor( 571 | 'SELECT id FROM Account WHERE Type = |', 572 | expectedItemsForLiterals( 573 | { 574 | sobjectName: 'Account', 575 | fieldName: 'Type', 576 | }, 577 | true 578 | ) 579 | ); 580 | validateCompletionsFor( 581 | "SELECT id FROM Account WHERE Type = 'Boo' OR Name = |", 582 | expectedItemsForLiterals( 583 | { 584 | sobjectName: 'Account', 585 | fieldName: 'Name', 586 | }, 587 | true 588 | ) 589 | ); 590 | validateCompletionsFor( 591 | "SELECT id FROM Account WHERE Type = 'Boo' OR Name LIKE |", 592 | expectedItemsForLiterals( 593 | { 594 | sobjectName: 'Account', 595 | fieldName: 'Name', 596 | }, 597 | false 598 | ) 599 | ); 600 | validateCompletionsFor( 601 | 'SELECT id FROM Account WHERE Account.Type = |', 602 | expectedItemsForLiterals( 603 | { 604 | sobjectName: 'Account', 605 | fieldName: 'Type', 606 | }, 607 | true 608 | ) 609 | ); 610 | 611 | validateCompletionsFor( 612 | 'SELECT Name FROM Account WHERE LastActivityDate < |', 613 | expectedItemsForLiterals( 614 | { 615 | sobjectName: 'Account', 616 | fieldName: 'LastActivityDate', 617 | }, 618 | false 619 | ) 620 | ); 621 | validateCompletionsFor( 622 | 'SELECT Name FROM Account WHERE LastActivityDate > |', 623 | expectedItemsForLiterals( 624 | { 625 | sobjectName: 'Account', 626 | fieldName: 'LastActivityDate', 627 | }, 628 | false 629 | ) 630 | ); 631 | }); 632 | 633 | describe('SELECT Function expressions', () => { 634 | validateCompletionsFor('SELECT DISTANCE(|) FROM Account', [ 635 | { 636 | kind: CompletionItemKind.Field, 637 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 638 | data: { soqlContext: { sobjectName: 'Account' } }, 639 | }, 640 | ]); 641 | 642 | validateCompletionsFor('SELECT AVG(|) FROM Account', [ 643 | { 644 | kind: CompletionItemKind.Field, 645 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 646 | data: { 647 | soqlContext: { 648 | sobjectName: 'Account', 649 | onlyAggregatable: true, 650 | onlyTypes: ['double', 'int', 'currency', 'percent'], 651 | }, 652 | }, 653 | }, 654 | ]); 655 | 656 | // COUNT is treated differently, always worth testing it separately 657 | validateCompletionsFor('SELECT COUNT(|) FROM Account', [ 658 | { 659 | kind: CompletionItemKind.Field, 660 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 661 | data: { 662 | soqlContext: { 663 | sobjectName: 'Account', 664 | onlyAggregatable: true, 665 | onlyTypes: [ 666 | 'date', 667 | 'datetime', 668 | 'double', 669 | 'int', 670 | 'string', 671 | 'combobox', 672 | 'currency', 673 | 'DataCategoryGroupReference', 674 | 'email', 675 | 'id', 676 | 'masterrecord', 677 | 'percent', 678 | 'phone', 679 | 'picklist', 680 | 'reference', 681 | 'textarea', 682 | 'url', 683 | ], 684 | }, 685 | }, 686 | }, 687 | ]); 688 | 689 | validateCompletionsFor('SELECT MAX(|) FROM Account', [ 690 | { 691 | kind: CompletionItemKind.Field, 692 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 693 | data: { 694 | soqlContext: { 695 | sobjectName: 'Account', 696 | onlyAggregatable: true, 697 | onlyTypes: [ 698 | 'date', 699 | 'datetime', 700 | 'double', 701 | 'int', 702 | 'string', 703 | 'time', 704 | 'combobox', 705 | 'currency', 706 | 'DataCategoryGroupReference', 707 | 'email', 708 | 'id', 709 | 'masterrecord', 710 | 'percent', 711 | 'phone', 712 | 'picklist', 713 | 'reference', 714 | 'textarea', 715 | 'url', 716 | ], 717 | }, 718 | }, 719 | }, 720 | ]); 721 | 722 | validateCompletionsFor('SELECT AVG(| FROM Account', [ 723 | { 724 | kind: CompletionItemKind.Field, 725 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 726 | data: { 727 | soqlContext: { 728 | sobjectName: 'Account', 729 | onlyAggregatable: true, 730 | onlyTypes: ['double', 'int', 'currency', 'percent'], 731 | }, 732 | }, 733 | }, 734 | ]); 735 | 736 | validateCompletionsFor('SELECT AVG(|), Id FROM Account', [ 737 | { 738 | kind: CompletionItemKind.Field, 739 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 740 | data: { 741 | soqlContext: { 742 | sobjectName: 'Account', 743 | onlyAggregatable: true, 744 | onlyTypes: ['double', 'int', 'currency', 'percent'], 745 | }, 746 | }, 747 | }, 748 | ]); 749 | validateCompletionsFor('SELECT Id, AVG(|) FROM Account', [ 750 | { 751 | kind: CompletionItemKind.Field, 752 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 753 | data: { 754 | soqlContext: { 755 | sobjectName: 'Account', 756 | onlyAggregatable: true, 757 | onlyTypes: ['double', 'int', 'currency', 'percent'], 758 | }, 759 | }, 760 | }, 761 | ]); 762 | 763 | // NOTE: cursor is right BEFORE the function expression: 764 | validateCompletionsFor('SELECT Id, | SUM(AnnualRevenue) FROM Account', [...sobjectsFieldsFor('Account')]); 765 | }); 766 | 767 | describe('Code Completion on "semi-join" (SELECT)', () => { 768 | validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT AccountId FROM |)', expectedSObjectCompletions); 769 | validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT FROM |)', expectedSObjectCompletions); 770 | 771 | // NOTE: The SELECT of a semi-join only accepts an "identifier" type column, no functions 772 | validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT | FROM Foo)', [ 773 | { 774 | kind: CompletionItemKind.Field, 775 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 776 | data: { soqlContext: { sobjectName: 'Foo', onlyTypes: ['id', 'reference'], dontShowRelationshipField: true } }, 777 | }, 778 | ]); 779 | 780 | // NOTE: The SELECT of a semi-join can only have one field, thus 781 | // we expect no completions here: 782 | validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT Id, | FROM Foo)', []); 783 | }); 784 | 785 | describe('Special cases around newlines', () => { 786 | validateCompletionsFor('SELECT id FROM|\n\n\n', []); 787 | validateCompletionsFor('SELECT id FROM |\n\n', expectedSObjectCompletions); 788 | validateCompletionsFor('SELECT id FROM\n|', expectedSObjectCompletions); 789 | validateCompletionsFor('SELECT id FROM\n\n|', expectedSObjectCompletions); 790 | validateCompletionsFor('SELECT id FROM\n|\n', expectedSObjectCompletions); 791 | validateCompletionsFor('SELECT id FROM\n\n|\n\n', expectedSObjectCompletions); 792 | validateCompletionsFor('SELECT id FROM\n\n\n\n\n\n|\n\n', expectedSObjectCompletions); 793 | validateCompletionsFor('SELECT id FROM\n\n|\n\nWHERE', expectedSObjectCompletions); 794 | validateCompletionsFor('SELECT id FROM\n\n|WHERE', expectedSObjectCompletions); 795 | }); 796 | 797 | describe('Support leading comment lines (starting with // )', () => { 798 | validateCompletionsFor( 799 | `// This a comment line 800 | SELECT id FROM |`, 801 | expectedSObjectCompletions 802 | ); 803 | }); 804 | describe('Support leading comment lines (starting with // )', () => { 805 | validateCompletionsFor( 806 | `// This a comment line 1 807 | // This a comment line 2 808 | // This a comment line 3 809 | SELECT id FROM |`, 810 | expectedSObjectCompletions 811 | ); 812 | }); 813 | 814 | function validateCompletionsFor( 815 | text: string, 816 | expectedItems: CompletionItem[], 817 | options: { skip?: boolean; only?: boolean; cursorChar?: string } = {} 818 | ): void { 819 | const itFn = options.skip ? xit : options.only ? it.only : it; 820 | const cursorChar = options.cursorChar || '|'; 821 | itFn(text, () => { 822 | if (text.indexOf(cursorChar) !== text.lastIndexOf(cursorChar)) { 823 | throw new Error(`Test text must have 1 and only 1 cursor (char: ${cursorChar})`); 824 | } 825 | 826 | const [line, column] = getCursorPosition(text, cursorChar); 827 | const completions = completionsFor(text.replace(cursorChar, ''), line, column); 828 | 829 | // NOTE: we don't use Sets here because when there are failures, the error 830 | // message is not useful 831 | expectedItems.forEach((item) => expect(completions).toContainEqual(item)); 832 | completions.forEach((item) => expect(expectedItems).toContainEqual(item)); 833 | }); 834 | } 835 | 836 | function getCursorPosition(text: string, cursorChar: string): [number, number] { 837 | for (const [line, lineText] of text.split('\n').entries()) { 838 | const column = lineText.indexOf(cursorChar); 839 | if (column >= 0) return [line + 1, column + 1]; 840 | } 841 | throw new Error(`Cursor ${cursorChar} not found in ${text} !`); 842 | } 843 | 844 | function sobjectsFieldsFor(sobjectName: string): CompletionItem[] { 845 | return [ 846 | { 847 | kind: CompletionItemKind.Field, 848 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 849 | data: { soqlContext: { sobjectName } }, 850 | }, 851 | ...newKeywordItems('TYPEOF'), 852 | newFunctionCallItem('AVG'), 853 | newFunctionCallItem('MIN'), 854 | newFunctionCallItem('MAX'), 855 | newFunctionCallItem('SUM'), 856 | newFunctionCallItem('COUNT'), 857 | newFunctionCallItem('COUNT_DISTINCT'), 858 | INNER_SELECT_SNIPPET, 859 | ]; 860 | } 861 | 862 | function relationshipFieldsFor(sobjectName: string, relationshipName?: string): CompletionItem[] { 863 | return [ 864 | { 865 | kind: CompletionItemKind.Field, 866 | label: '__RELATIONSHIP_FIELDS_PLACEHOLDER', 867 | data: { soqlContext: { sobjectName, relationshipName } }, 868 | }, 869 | ...newKeywordItems('TYPEOF'), 870 | ]; 871 | } 872 | --------------------------------------------------------------------------------