From d9f1f59a99b2833f984f105de14a8cdd1edffcda Mon Sep 17 00:00:00 2001 From: Favour Ohanekwu Date: Fri, 12 May 2023 06:56:53 +0100 Subject: [PATCH] feat: Autocompletion hints in sql editor (#22827) ## Description This PR introduces autocompletion hints in the SQL editor Fixes #17441 Media Screenshot 2023-05-07 at 14 31 11 Screenshot 2023-05-07 at 14 31 48 ## Type of change > Please delete options that are not relevant. - New feature (non-breaking change which adds functionality) ## How Has This Been Tested? > Please describe the tests that you ran to verify your changes. Provide instructions, so we can reproduce. > Please also list any relevant details for your test configuration. > Delete anything that is not important - Manual - Jest - Cypress ### Test Plan https://github.com/appsmithorg/TestSmith/issues/2381 ### Issues raised during DP testing https://github.com/appsmithorg/appsmith/pull/22827#issuecomment-1536164809 ## Checklist: ### Dev activity - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] PR is being merged under a feature flag ### QA activity: - [ ] Test plan has been approved by relevant developers - [ ] Test plan has been peer reviewed by QA - [ ] Cypress test cases have been added and approved by either SDET or manual QA - [ ] Organized project review call with relevant stakeholders after Round 1/2 of QA - [ ] Added Test Plan Approved label after reveiwing all Cypress test --- .../Autocomplete/Autocomplete_sql_spec.js | 25 ++++ .../QueryPane/Postgres_Spec.js | 4 +- app/client/package.json | 2 +- .../CodeEditor/EditorConfig.ts | 30 ++-- .../CodeEditor/codeEditorUtils.ts | 14 ++ .../CodeEditor/customSQLHint.tsx | 130 ++++++++++++++++++ .../CodeEditor/hintHelpers.ts | 101 +++++++++++++- .../editorComponents/CodeEditor/index.tsx | 18 ++- .../editorComponents/CodeEditor/modes.ts | 32 +++-- .../editorComponents/CodeEditor/sql/config.ts | 103 ++++++++++++++ .../CodeEditor/sql/customMimes/arango.ts | 22 +++ .../CodeEditor/sql/customMimes/index.ts | 3 + .../CodeEditor/sql/customMimes/redis.ts | 22 +++ .../CodeEditor/sql/customMimes/snowflake.ts | 22 +++ .../CodeEditor/sql/customMimes/utils.ts | 6 + .../CodeEditor/utils/codeComment.ts | 21 ++- .../form/fields/DynamicTextField.tsx | 7 +- .../formControls/DynamicTextFieldControl.tsx | 12 +- .../src/globalStyles/CodemirrorHintStyles.ts | 19 +++ app/client/src/selectors/entitiesSelector.ts | 33 +++++ app/yarn.lock | 10 +- 21 files changed, 580 insertions(+), 56 deletions(-) create mode 100644 app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Autocomplete/Autocomplete_sql_spec.js create mode 100644 app/client/src/components/editorComponents/CodeEditor/customSQLHint.tsx create mode 100644 app/client/src/components/editorComponents/CodeEditor/sql/config.ts create mode 100644 app/client/src/components/editorComponents/CodeEditor/sql/customMimes/arango.ts create mode 100644 app/client/src/components/editorComponents/CodeEditor/sql/customMimes/index.ts create mode 100644 app/client/src/components/editorComponents/CodeEditor/sql/customMimes/redis.ts create mode 100644 app/client/src/components/editorComponents/CodeEditor/sql/customMimes/snowflake.ts create mode 100644 app/client/src/components/editorComponents/CodeEditor/sql/customMimes/utils.ts diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Autocomplete/Autocomplete_sql_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Autocomplete/Autocomplete_sql_spec.js new file mode 100644 index 0000000000..0b213310b7 --- /dev/null +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Autocomplete/Autocomplete_sql_spec.js @@ -0,0 +1,25 @@ +const queryLocators = require("../../../../locators/QueryEditor.json"); +const datasource = require("../../../../locators/DatasourcesEditor.json"); +import { ObjectsRegistry } from "../../../../support/Objects/Registry"; + +const locator = ObjectsRegistry.CommonLocators; +let datasourceName; + +describe("SQL Autocompletion", function () { + it("Shows autocompletion hints", function () { + cy.NavigateToDatasourceEditor(); + cy.get(datasource.PostgreSQL).click(); + cy.fillPostgresDatasourceForm(); + + cy.generateUUID().then((uid) => { + datasourceName = `Postgres CRUD ds ${uid}`; + cy.renameDatasource(datasourceName); + }); + cy.testSaveDatasource(); + cy.NavigateToActiveDSQueryPane(datasourceName); + cy.get(queryLocators.templateMenu).click({ force: true }); + cy.get(".CodeMirror textarea").focus().type("S"); + cy.get(locator._hints).should("exist"); + cy.deleteQueryUsingContext(); + }); +}); diff --git a/app/client/cypress/integration/Regression_TestSuite/ServerSideTests/QueryPane/Postgres_Spec.js b/app/client/cypress/integration/Regression_TestSuite/ServerSideTests/QueryPane/Postgres_Spec.js index a324440a20..e74618bbc3 100644 --- a/app/client/cypress/integration/Regression_TestSuite/ServerSideTests/QueryPane/Postgres_Spec.js +++ b/app/client/cypress/integration/Regression_TestSuite/ServerSideTests/QueryPane/Postgres_Spec.js @@ -84,9 +84,7 @@ describe("Validate CRUD queries for Postgres along with UI flow verifications", cy.get(queryLocators.templateMenu).click({ force: true }); //cy.typeValueNValidate(tableCreateQuery);//Since type method is slow for such big text - using paste! - - cy.get(".CodeMirror textarea").paste(tableCreateQuery); - cy.get(".CodeMirror textarea").focus(); + cy.get(".CodeMirror textarea").focus().paste(tableCreateQuery); cy.EvaluateCurrentValue(tableCreateQuery); cy.wait(3000); cy.runAndDeleteQuery(); //exeute actions - 200 response is verified in this method diff --git a/app/client/package.json b/app/client/package.json index 8bc88e7b0f..4738d306e4 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -67,7 +67,7 @@ "axios": "^0.27.2", "classnames": "^2.3.1", "clsx": "^1.2.1", - "codemirror": "^5.59.2", + "codemirror": "^5.65.13", "codemirror-graphql": "^1.2.14", "copy-to-clipboard": "^3.3.1", "core-js": "^3.9.1", diff --git a/app/client/src/components/editorComponents/CodeEditor/EditorConfig.ts b/app/client/src/components/editorComponents/CodeEditor/EditorConfig.ts index cced3703cb..39b96de8c0 100644 --- a/app/client/src/components/editorComponents/CodeEditor/EditorConfig.ts +++ b/app/client/src/components/editorComponents/CodeEditor/EditorConfig.ts @@ -5,17 +5,21 @@ import type { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataTy import type { EntityNavigationData } from "selectors/navigationSelectors"; import type { ExpectedValueExample } from "utils/validation/common"; -export enum EditorModes { - TEXT = "text/plain", - SQL = "sql", - TEXT_WITH_BINDING = "text-js", - JSON = "application/json", - JSON_WITH_BINDING = "json-js", - SQL_WITH_BINDING = "sql-js", - JAVASCRIPT = "javascript", - GRAPHQL = "graphql", - GRAPHQL_WITH_BINDING = "graphql-js", -} +import { editorSQLModes } from "./sql/config"; + +export const EditorModes = { + TEXT: "text/plain", + TEXT_WITH_BINDING: "text-js", + JSON: "application/json", + JSON_WITH_BINDING: "json-js", + JAVASCRIPT: "javascript", + GRAPHQL: "graphql", + GRAPHQL_WITH_BINDING: "graphql-js", + ...editorSQLModes, +} as const; + +type ValueOf = T[keyof T]; +export type TEditorModes = ValueOf; export enum EditorTheme { LIGHT = "LIGHT", @@ -34,7 +38,7 @@ export enum EditorSize { export type EditorConfig = { theme: EditorTheme; - mode: EditorModes; + mode: TEditorModes; tabBehaviour: TabBehaviour; size: EditorSize; hinting?: Array; @@ -55,7 +59,7 @@ export type FieldEntityInformation = { propertyPath?: string; blockCompletions?: Array<{ parentPath: string; subPath: string }>; example?: ExpectedValueExample; - mode?: EditorModes; + mode?: TEditorModes; }; export type HintHelper = ( diff --git a/app/client/src/components/editorComponents/CodeEditor/codeEditorUtils.ts b/app/client/src/components/editorComponents/CodeEditor/codeEditorUtils.ts index 439eb59336..7cd1aac591 100644 --- a/app/client/src/components/editorComponents/CodeEditor/codeEditorUtils.ts +++ b/app/client/src/components/editorComponents/CodeEditor/codeEditorUtils.ts @@ -2,6 +2,7 @@ import type CodeMirror from "codemirror"; import { ENTITY_TYPE } from "entities/AppsmithConsole"; import type { WidgetEntity } from "entities/DataTree/dataTreeFactory"; import type { ActionEntity } from "entities/DataTree/types"; +import { trim } from "lodash"; import { getDynamicStringSegments } from "utils/DynamicBindingUtils"; import { EditorSize } from "./EditorConfig"; @@ -121,3 +122,16 @@ export const removeNewLineCharsIfRequired = ( } return resultVal; }; + +// Checks if string at the position of the cursor is empty +export function isCursorOnEmptyToken(editor: CodeMirror.Editor) { + const currentCursorPosition = editor.getCursor(); + const { string: stringAtCurrentPosition } = editor.getTokenAt( + currentCursorPosition, + ); + const isEmptyString = !( + stringAtCurrentPosition && trim(stringAtCurrentPosition) + ); + + return isEmptyString; +} diff --git a/app/client/src/components/editorComponents/CodeEditor/customSQLHint.tsx b/app/client/src/components/editorComponents/CodeEditor/customSQLHint.tsx new file mode 100644 index 0000000000..94bc8adc1f --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/customSQLHint.tsx @@ -0,0 +1,130 @@ +import { theme } from "constants/DefaultTheme"; +import React from "react"; +import ReactDOM from "react-dom"; +import { sqlHint } from "./hintHelpers"; + +const hintContainerStyles: React.CSSProperties = { + display: "flex", + alignItems: "center", + width: "100%", +}; +const getHintIconStyles = (bgColor: string): React.CSSProperties => { + return { + background: bgColor, + textAlign: "center", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "12px", + height: "12px", + marginLeft: "3px", + color: "#858282", + lineHeight: "12px", + }; +}; + +const hintStyles: React.CSSProperties = { + paddingLeft: "5px", + height: "24px", + display: "flex", + justifyContent: "flex-start", + alignItems: "center", + flex: 1, + lineHeight: "15px", + letterSpacing: "-0.24px", + fontSize: "12px", +}; + +const hintLabelStyles: React.CSSProperties = { + fontStyle: "italic", + letterSpacing: "-0.24px", + fontWeight: "normal", + padding: "0 10px", + lineHeight: "13px", + fontSize: "10px", +}; + +enum SQLHintType { + unknown = "unknown", + keyword = "keyword", + text = "text", + int4 = "int4", + table = "table", +} + +const SQLDataTypeToBgColor: Record< + string, + NonNullable +> = { + unknown: theme.colors.dataTypeBg.unknown, + keyword: theme.colors.dataTypeBg.object, + text: theme.colors.dataTypeBg.number, + int4: theme.colors.dataTypeBg.array, + table: theme.colors.dataTypeBg.function, +}; +function getHintDetailsFromClassName( + text: string, + className?: string, +): { + hintType: string; + iconText: string; + iconBg: string; +} { + switch (className) { + case "CodeMirror-hint-table": + const hintDataType = sqlHint.datasourceTableKeys[text]; + return hintDataType + ? { + hintType: hintDataType, + iconText: hintDataType.charAt(0).toLocaleUpperCase(), + iconBg: + SQLDataTypeToBgColor[hintDataType] || + SQLDataTypeToBgColor.unknown, + } + : { + hintType: SQLHintType.unknown, + iconText: "U", + iconBg: SQLDataTypeToBgColor.unknown, + }; + + case "CodeMirror-hint-keyword": + return { + hintType: SQLHintType.keyword, + iconText: "K", + iconBg: "#FFD6A5", + }; + default: + return { hintType: SQLHintType.unknown, iconText: "U", iconBg: "#4bb" }; + } +} + +// Avoiding styled components since ReactDOM.render cannot directly work with it +export default function CustomHint({ + className, + text, +}: { + text: string; + className?: string; +}) { + const { hintType, iconBg, iconText } = getHintDetailsFromClassName( + text, + className, + ); + return ( +
+ {iconText} +
{text}
+ + {hintType} + +
+ ); +} + +export function renderHint( + LiElement: HTMLLIElement, + text: string, + className?: string, +) { + ReactDOM.render(, LiElement); +} diff --git a/app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts b/app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts index c8ddc6bcc9..f46742d26e 100644 --- a/app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts +++ b/app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts @@ -1,10 +1,18 @@ -import type CodeMirror from "codemirror"; +import type { Hints } from "codemirror"; +import CodeMirror from "codemirror"; import CodemirrorTernService from "utils/autocomplete/CodemirrorTernService"; import KeyboardShortcuts from "constants/KeyboardShortcuts"; import type { HintHelper } from "components/editorComponents/CodeEditor/EditorConfig"; +import { EditorModes } from "components/editorComponents/CodeEditor/EditorConfig"; import AnalyticsUtil from "utils/AnalyticsUtil"; -import { checkIfCursorInsideBinding } from "components/editorComponents/CodeEditor/codeEditorUtils"; +import { + checkIfCursorInsideBinding, + isCursorOnEmptyToken, +} from "components/editorComponents/CodeEditor/codeEditorUtils"; import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory"; +import { isEmpty, isString } from "lodash"; +import type { getAllDatasourceTableKeys } from "selectors/entitiesSelector"; +import { renderHint } from "./customSQLHint"; export const bindingHint: HintHelper = (editor) => { editor.setOption("extraKeys", { @@ -52,3 +60,92 @@ export const bindingHint: HintHelper = (editor) => { }, }; }; + +type HandleCompletions = ( + editor: CodeMirror.Editor, +) => + | { showHints: false; completions: null } + | { showHints: true; completions: Hints }; + +class SqlHintHelper { + datasourceTableKeys: NonNullable< + ReturnType + > = {}; + tables: Record = {}; + + constructor() { + this.hinter = this.hinter.bind(this); + this.setDatasourceTableKeys = this.setDatasourceTableKeys.bind(this); + this.addCustomRendererToCompletions = + this.addCustomRendererToCompletions.bind(this); + this.generateTables = this.generateTables.bind(this); + } + + setDatasourceTableKeys( + datasourceTableKeys: ReturnType, + ) { + this.datasourceTableKeys = datasourceTableKeys || {}; + this.tables = this.generateTables(this.datasourceTableKeys); + } + hinter() { + return { + showHint: (editor: CodeMirror.Editor): boolean => { + const { completions, showHints } = this.handleCompletions(editor); + if (!showHints) return false; + editor.showHint({ + hint: () => { + return completions; + }, + completeSingle: false, + alignWithWord: false, + extraKeys: { + Tab: (editor: CodeMirror.Editor, handle) => { + handle.pick(); + }, + }, + }); + return true; + }, + }; + } + + generateTables(tableKeys: typeof this.datasourceTableKeys) { + const tables: Record = {}; + for (const tableKey of Object.keys(tableKeys)) { + tables[`${tableKey}`] = []; + } + return tables; + } + + isSqlMode(editor: CodeMirror.Editor) { + const editorMode = editor.getModeAt(editor.getCursor()); + return editorMode?.name === EditorModes.SQL; + } + + addCustomRendererToCompletions(completions: Hints): Hints { + completions.list = completions.list.map((completion) => { + if (isString(completion)) return completion; + completion.render = (LiElement, data, currentHint) => { + renderHint(LiElement, currentHint.text, currentHint.className); + }; + return completion; + }); + return completions; + } + + handleCompletions(editor: CodeMirror.Editor): ReturnType { + const noHints = { showHints: false, completions: null } as const; + if (isCursorOnEmptyToken(editor) || !this.isSqlMode(editor)) return noHints; + // @ts-expect-error: No types available + const completions: Hints = CodeMirror.hint.sql(editor, { + tables: this.tables, + }); + if (isEmpty(completions.list)) return noHints; + return { + completions: this.addCustomRendererToCompletions(completions), + showHints: true, + }; + } +} + +export const sqlHint = new SqlHintHelper(); diff --git a/app/client/src/components/editorComponents/CodeEditor/index.tsx b/app/client/src/components/editorComponents/CodeEditor/index.tsx index 5aaad23b03..b9c2ce280c 100644 --- a/app/client/src/components/editorComponents/CodeEditor/index.tsx +++ b/app/client/src/components/editorComponents/CodeEditor/index.tsx @@ -20,6 +20,10 @@ import "codemirror/addon/tern/tern.css"; import "codemirror/addon/lint/lint"; import "codemirror/addon/lint/lint.css"; import "codemirror/addon/comment/comment"; +import "codemirror/mode/sql/sql.js"; +import "codemirror/addon/hint/show-hint"; +import "codemirror/addon/hint/show-hint.css"; +import "codemirror/addon/hint/sql-hint"; import { getDataTreeForAutocomplete } from "selectors/dataTreeSelectors"; import EvaluatedValuePopup from "components/editorComponents/CodeEditor/EvaluatedValuePopup"; import type { WrappedFieldInputProps } from "redux-form"; @@ -33,6 +37,7 @@ import type { import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory"; import { Skin } from "constants/DefaultTheme"; import AnalyticsUtil from "utils/AnalyticsUtil"; +import "components/editorComponents/CodeEditor/sql/customMimes"; import "components/editorComponents/CodeEditor/modes"; import type { CodeEditorBorder, @@ -66,7 +71,10 @@ import { PEEKABLE_LINE, PEEK_STYLE_PERSIST_CLASS, } from "components/editorComponents/CodeEditor/MarkHelpers/entityMarker"; -import { bindingHint } from "components/editorComponents/CodeEditor/hintHelpers"; +import { + bindingHint, + sqlHint, +} from "components/editorComponents/CodeEditor/hintHelpers"; import BindingPrompt from "./BindingPrompt"; import { showBindingPrompt } from "./BindingPromptHelper"; import { Button, ScrollIndicator } from "design-system-old"; @@ -139,6 +147,7 @@ import { } from "./utils/saveAndAutoIndent"; import { getAssetUrl } from "@appsmith/utils/airgapHelpers"; import { selectFeatureFlags } from "selectors/usersSelectors"; +import { getAllDatasourceTableKeys } from "selectors/entitiesSelector"; type ReduxStateProps = ReturnType; type ReduxDispatchProps = ReturnType; @@ -249,7 +258,7 @@ const getEditorIdentifier = (props: EditorProps): string => { class CodeEditor extends Component { static defaultProps = { marking: [bindingMarker, entityMarker], - hinting: [bindingHint, commandsHelper], + hinting: [bindingHint, commandsHelper, sqlHint.hinter], lineCommentString: "//", }; // this is the higlighted element for any highlighted text in the codemirror @@ -426,6 +435,7 @@ class CodeEditor extends Component { } }, 200); }.bind(this); + sqlHint.setDatasourceTableKeys(this.props.datasourceTableKeys); // Finally create the Codemirror editor this.editor = CodeMirror(this.codeEditorTarget.current, options); @@ -534,6 +544,9 @@ class CodeEditor extends Component { this.props.entitiesForNavigation, ); } + if (this.props.datasourceTableKeys !== prevProps.datasourceTableKeys) { + sqlHint.setDatasourceTableKeys(this.props.datasourceTableKeys); + } }); } @@ -1459,6 +1472,7 @@ const mapStateToProps = (state: AppState, props: EditorProps) => ({ props.dataTreePath?.split(".")[0], ), featureFlags: selectFeatureFlags(state), + datasourceTableKeys: getAllDatasourceTableKeys(state, props.dataTreePath), }); const mapDispatchToProps = (dispatch: any) => ({ diff --git a/app/client/src/components/editorComponents/CodeEditor/modes.ts b/app/client/src/components/editorComponents/CodeEditor/modes.ts index d55dd58341..5a5a5cf00c 100644 --- a/app/client/src/components/editorComponents/CodeEditor/modes.ts +++ b/app/client/src/components/editorComponents/CodeEditor/modes.ts @@ -4,6 +4,7 @@ import "codemirror/addon/mode/multiplex"; import "codemirror/mode/javascript/javascript"; import "codemirror/mode/sql/sql"; import "codemirror/addon/hint/sql-hint"; +import { sqlModesConfig } from "./sql/config"; CodeMirror.defineMode(EditorModes.TEXT_WITH_BINDING, function (config) { // @ts-expect-error: Types are not available @@ -33,20 +34,6 @@ CodeMirror.defineMode(EditorModes.JSON_WITH_BINDING, function (config) { ); }); -CodeMirror.defineMode(EditorModes.SQL_WITH_BINDING, function (config) { - // @ts-expect-error: Types are not available - return CodeMirror.multiplexingMode( - CodeMirror.getMode(config, EditorModes.SQL), - { - open: "{{", - close: "}}", - mode: CodeMirror.getMode(config, { - name: "javascript", - }), - }, - ); -}); - CodeMirror.defineMode(EditorModes.GRAPHQL_WITH_BINDING, function (config) { // @ts-expect-error: Types are not available return CodeMirror.multiplexingMode( @@ -67,3 +54,20 @@ CodeMirror.defineMode(EditorModes.GRAPHQL_WITH_BINDING, function (config) { }, ); }); + +for (const sqlModeConfig of Object.values(sqlModesConfig)) { + if (!sqlModeConfig.isMultiplex) continue; + CodeMirror.defineMode(sqlModeConfig.mode, function (config) { + // @ts-expect-error: Types are not available + return CodeMirror.multiplexingMode( + CodeMirror.getMode(config, sqlModeConfig.mime), + { + open: "{{", + close: "}}", + mode: CodeMirror.getMode(config, { + name: "javascript", + }), + }, + ); + }); +} diff --git a/app/client/src/components/editorComponents/CodeEditor/sql/config.ts b/app/client/src/components/editorComponents/CodeEditor/sql/config.ts new file mode 100644 index 0000000000..2df2aaa5b7 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/sql/config.ts @@ -0,0 +1,103 @@ +import { find } from "lodash"; +import type { TEditorModes } from "../EditorConfig"; + +type ValueOf = T[keyof T]; +export type TEditorSqlModes = ValueOf; + +type SqlModeConfig = Record< + TEditorSqlModes, + { + mime: string; + mode: TEditorSqlModes; + // CodeMirror.multiplexingMode + isMultiplex: boolean; + } +>; + +export const editorSQLModes = { + // SQL only + SQL: "sql", + // SQL flavour + JS + SNOWFLAKE_WITH_BINDING: "snowflakesql-js", + ARANGO_WITH_BINDING: "arangosql-js", + REDIS_WITH_BINDING: "redissql-js", + POSTGRESQL_WITH_BINDING: "pgsql-js", + SQL_WITH_BINDING: "sql-js", + MYSQL_WITH_BINDING: "mysql-js", + MSSQL_WITH_BINDING: "mssql-js", + PLSQL_WITH_BINDING: "plsql-js", +} as const; + +// Mime available in sql mode https://github.com/codemirror/codemirror5/blob/9974ded36bf01746eb2a00926916fef834d3d0d0/mode/sql/sql.js#L290 +export const sqlModesConfig: SqlModeConfig = { + [editorSQLModes.SQL]: { + mime: "sql", + mode: editorSQLModes.SQL, + isMultiplex: false, + }, + [editorSQLModes.SQL_WITH_BINDING]: { + mime: "text/x-sql", + mode: editorSQLModes.SQL_WITH_BINDING, + isMultiplex: true, + }, + [editorSQLModes.MYSQL_WITH_BINDING]: { + mime: "text/x-mysql", + mode: editorSQLModes.MYSQL_WITH_BINDING, + isMultiplex: true, + }, + [editorSQLModes.MSSQL_WITH_BINDING]: { + mime: "text/x-mssql", + mode: editorSQLModes.MSSQL_WITH_BINDING, + isMultiplex: true, + }, + [editorSQLModes.PLSQL_WITH_BINDING]: { + mime: "text/x-plsql", + mode: editorSQLModes.PLSQL_WITH_BINDING, + isMultiplex: true, + }, + [editorSQLModes.POSTGRESQL_WITH_BINDING]: { + mime: "text/x-pgsql", + mode: editorSQLModes.POSTGRESQL_WITH_BINDING, + isMultiplex: true, + }, + // Custom mimes + [editorSQLModes.SNOWFLAKE_WITH_BINDING]: { + mime: "text/x-snowflakesql", + mode: editorSQLModes.SNOWFLAKE_WITH_BINDING, + isMultiplex: true, + }, + [editorSQLModes.ARANGO_WITH_BINDING]: { + mime: "text/x-arangosql", + mode: editorSQLModes.ARANGO_WITH_BINDING, + isMultiplex: true, + }, + [editorSQLModes.REDIS_WITH_BINDING]: { + mime: "text/x-redis", + mode: editorSQLModes.REDIS_WITH_BINDING, + isMultiplex: true, + }, +}; + +export const pluginNameToSqlMIME: Record = { + PostgreSQL: editorSQLModes.POSTGRESQL_WITH_BINDING, + MySQL: editorSQLModes.MYSQL_WITH_BINDING, + "Microsoft SQL Server": editorSQLModes.MSSQL_WITH_BINDING, + Oracle: editorSQLModes.PLSQL_WITH_BINDING, + Redshift: editorSQLModes.PLSQL_WITH_BINDING, + Snowflake: editorSQLModes.SNOWFLAKE_WITH_BINDING, + ArangoDB: editorSQLModes.ARANGO_WITH_BINDING, + Redis: editorSQLModes.REDIS_WITH_BINDING, +}; + +export function getSqlEditorModeFromPluginName(name: string) { + return pluginNameToSqlMIME[name] ?? editorSQLModes.SQL_WITH_BINDING; +} + +export function getSqlMimeFromMode(mode: TEditorSqlModes) { + const modeConfig = find(sqlModesConfig, { mode }); + return modeConfig?.mime ?? "text/x-sql"; +} + +export function isSqlMode(mode: TEditorModes) { + return !!Object.keys(sqlModesConfig).includes(mode); +} diff --git a/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/arango.ts b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/arango.ts new file mode 100644 index 0000000000..e3313a5f63 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/arango.ts @@ -0,0 +1,22 @@ +import CodeMirror from "codemirror"; +import { merge } from "lodash"; +import { EditorModes } from "../../EditorConfig"; +import { getSqlMimeFromMode } from "../config"; +import { spaceSeparatedStringToObject } from "./utils"; + +// @ts-expect-error: No type available +const defaultSQLConfig = CodeMirror.resolveMode("text/x-sql"); + +export const arangoKeywordsMap = { + // https://www.arangodb.com/docs/stable/aql/fundamentals-syntax.html + keywords: spaceSeparatedStringToObject( + "for return filter search sort limit let collect window insert update replace remove upsert with aggregate all all_shortest_paths and any asc collect desc distinct false filter for graph in inbound insert into k_paths k_shortest_paths let like limit none not null or outbound remove replace return shortest_path sort true update upsert window with keep count options prune search to current new", + ), +}; +const arangoConfig = merge(defaultSQLConfig, arangoKeywordsMap); + +// Inspired by https://github.com/codemirror/codemirror5/blob/9974ded36bf01746eb2a00926916fef834d3d0d0/mode/sql/sql.js#L290 +CodeMirror.defineMIME( + getSqlMimeFromMode(EditorModes.ARANGO_WITH_BINDING), + arangoConfig, +); diff --git a/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/index.ts b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/index.ts new file mode 100644 index 0000000000..9857e4e515 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/index.ts @@ -0,0 +1,3 @@ +import "./arango"; +import "./snowflake"; +import "./redis"; diff --git a/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/redis.ts b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/redis.ts new file mode 100644 index 0000000000..f4e68ecefd --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/redis.ts @@ -0,0 +1,22 @@ +import CodeMirror from "codemirror"; +import { merge } from "lodash"; +import { EditorModes } from "../../EditorConfig"; +import { getSqlMimeFromMode } from "../config"; +import { spaceSeparatedStringToObject } from "./utils"; + +// @ts-expect-error: No type available +const defaultSQLConfig = CodeMirror.resolveMode("text/x-sql"); + +export const redisKeywordsMap = { + // https://redis.io/commands/ + keywords: spaceSeparatedStringToObject( + "acl cat deluser dryrun genpass getuser list load log save setuser users whoami append asking auth bf.add bf.card bf.exists bf.info bf.insert bf.loadchunk bf.madd bf.mexists bf.reserve bf.scandump bgrewriteaof bgsave bitcount bitfield bitfield_ro bitop bitpos blmove blmpop blpop brpop brpoplpush bzmpop bzpopmax bzpopmin cf.add cf.addnx cf.count cf.del cf.exists cf.info cf.insert cf.insertnx cf.loadchunk cf.mexists cf.reserve cf.scandump client caching getname getredir id info kill no-evict no-touch pause reply setinfo setname tracking trackinginfo unblock unpause cluster addslots addslotsrange bumpepoch count-failure-reports countkeysinslot delslots delslotsrange failover flushslots forget getkeysinslot keyslot links meet myid myshardid nodes replicas replicate reset saveconfig set-config-epoch setslot shards slaves slots cms.incrby cms.info cms.initbydim cms.initbyprob cms.merge cms.query command count docs getkeys getkeysandflags config get resetstat rewrite set copy dbsize decr decrby del discard dump echo eval eval_ro evalsha evalsha_ro exec exists expire expireat expiretime fcall fcall_ro flushall flushdb ft._list ft.aggregate ft.aliasadd ft.aliasdel ft.aliasupdate ft.alter ft.create ft.dictadd ft.dictdel ft.dictdump ft.dropindex ft.explain ft.explaincli ft.info ft.profile ft.search ft.spellcheck ft.sugadd ft.sugdel ft.sugget ft.suglen ft.syndump ft.synupdate ft.tagvals function delete flush restore stats geoadd geodist geohash geopos georadius georadius_ro georadiusbymember georadiusbymember_ro geosearch geosearchstore getbit getdel getex getrange getset graph.config graph.constraint create drop graph.delete graph.explain graph.list graph.profile graph.query graph.ro_query graph.slowlog hdel hello hexists hget hgetall hincrby hincrbyfloat hkeys hlen hmget hmset hrandfield hscan hset hsetnx hstrlen hvals incr incrby incrbyfloat json.arrappend json.arrindex json.arrinsert json.arrlen json.arrpop json.arrtrim json.clear json.debug json.del json.forget json.get json.mget json.numincrby json.nummultby json.objkeys json.objlen json.resp json.set json.strappend json.strlen json.toggle json.type keys lastsave latency doctor graph histogram history latest lcs lindex linsert llen lmove lmpop lolwut lpop lpos lpush lpushx lrange lrem lset ltrim mget migrate module loadex unload monitor move mset msetnx multi object encoding freq idletime refcount persist pexpire pexpireat pexpiretime pfadd pfcount pfdebug pfmerge pfselftest ping psetex psubscribe psync pttl publish pubsub channels numpat numsub shardchannels shardnumsub punsubscribe quit randomkey readonly readwrite rename renamenx replconf replicaof restore-asking role rpop rpoplpush rpush rpushx sadd scan scard script debug sdiff sdiffstore select setbit setex setnx setrange shutdown sinter sintercard sinterstore sismember slaveof slowlog len smembers smismember smove sort sort_ro spop spublish srandmember srem sscan ssubscribe strlen subscribe substr sunion sunionstore sunsubscribe swapdb sync tdigest.add tdigest.byrank tdigest.byrevrank tdigest.cdf tdigest.create tdigest.info tdigest.max tdigest.merge tdigest.min tdigest.quantile tdigest.rank tdigest.reset tdigest.revrank tdigest.trimmed_mean time topk.add topk.count topk.incrby topk.info topk.list topk.query topk.reserve touch ts.add ts.alter ts.create ts.createrule ts.decrby ts.del ts.deleterule ts.get ts.incrby ts.info ts.madd ts.mget ts.mrange ts.mrevrange ts.queryindex ts.range ts.revrange ttl type unlink unsubscribe unwatch wait waitaof watch xack xadd xautoclaim xclaim xdel xgroup createconsumer delconsumer destroy setid xinfo consumers groups stream xlen xpending xrange xread xreadgroup xrevrange xsetid xtrim zadd zcard zcount zdiff zdiffstore zincrby zinter zintercard zinterstore zlexcount zmpop zmscore zpopmax zpopmin zrandmember zrange zrangebylex zrangebyscore zrangestore zrank zrem zremrangebylex zremrangebyrank zremrangebyscore zrevrange zrevrangebylex zrevrangebyscore zrevrank zscan zscore zunion zunionstore", + ), +}; +const redisConfig = merge(defaultSQLConfig, redisKeywordsMap); + +// Inspired by https://github.com/codemirror/codemirror5/blob/9974ded36bf01746eb2a00926916fef834d3d0d0/mode/sql/sql.js#L290 +CodeMirror.defineMIME( + getSqlMimeFromMode(EditorModes.REDIS_WITH_BINDING), + redisConfig, +); diff --git a/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/snowflake.ts b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/snowflake.ts new file mode 100644 index 0000000000..03be9d7ae0 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/snowflake.ts @@ -0,0 +1,22 @@ +import CodeMirror from "codemirror"; +import { merge } from "lodash"; +import { EditorModes } from "../../EditorConfig"; +import { getSqlMimeFromMode } from "../config"; +import { spaceSeparatedStringToObject } from "./utils"; + +// @ts-expect-error: No type available +const defaultSQLConfig = CodeMirror.resolveMode("text/x-sql"); + +export const snowflakeKeywordsMap = { + // Ref: https://docs.snowflake.com/en/sql-reference/reserved-keywords + keywords: spaceSeparatedStringToObject( + "account all alter and any as between by case cast check column connect connection constraint create cross current current_date current_time current_timestamp current_user database delete distinct drop else exists false following for from full grant group gscluster having ilike in increment inner insert intersect into is issue join lateral left like localtime localtimestamp minus natural not null of on or order organization qualify regexp revoke right rlike row rows sample schema select set some start table tablesample then to trigger true try_cast union unique update using values view when whenever where with", + ), +}; +const snowflakeConfig = merge(defaultSQLConfig, snowflakeKeywordsMap); + +// Inspired by https://github.com/codemirror/codemirror5/blob/9974ded36bf01746eb2a00926916fef834d3d0d0/mode/sql/sql.js#L290 +CodeMirror.defineMIME( + getSqlMimeFromMode(EditorModes.SNOWFLAKE_WITH_BINDING), + snowflakeConfig, +); diff --git a/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/utils.ts b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/utils.ts new file mode 100644 index 0000000000..231f3bf71e --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/sql/customMimes/utils.ts @@ -0,0 +1,6 @@ +export function spaceSeparatedStringToObject(str: string) { + const result: Record = {}; + const words = str.split(" "); + for (const eachWord of words) result[eachWord] = true; + return result; +} diff --git a/app/client/src/components/editorComponents/CodeEditor/utils/codeComment.ts b/app/client/src/components/editorComponents/CodeEditor/utils/codeComment.ts index 5d373908a6..38eb7de4b0 100644 --- a/app/client/src/components/editorComponents/CodeEditor/utils/codeComment.ts +++ b/app/client/src/components/editorComponents/CodeEditor/utils/codeComment.ts @@ -1,6 +1,8 @@ import CodeMirror from "codemirror"; import { getPlatformOS } from "utils/helpers"; +import type { TEditorModes } from "../EditorConfig"; import { EditorModes } from "../EditorConfig"; +import { isSqlMode } from "../sql/config"; import { KEYBOARD_SHORTCUTS_BY_PLATFORM } from "./keyboardShortcutConstants"; export const getCodeCommentKeyMap = () => { @@ -8,14 +10,8 @@ export const getCodeCommentKeyMap = () => { return KEYBOARD_SHORTCUTS_BY_PLATFORM[platformOS].codeComment; }; -export function getLineCommentString(mode: EditorModes) { - switch (mode) { - case EditorModes.SQL: - case EditorModes.SQL_WITH_BINDING: - return "--"; - default: - return "//"; - } +export function getLineCommentString(editorMode: TEditorModes) { + return isSqlMode(editorMode) ? "--" : "//"; } // Most of the code below is copied from https://github.com/codemirror/codemirror5/blob/master/addon/comment/comment.js @@ -56,10 +52,11 @@ const noOptions: CodeMirror.CommentOptions = {}; /** * Gives index of the first non whitespace character in the line **/ -function firstNonWhitespace(str: string, mode: EditorModes) { +function firstNonWhitespace(str: string, mode: TEditorModes) { const found = str.search( - [EditorModes.JAVASCRIPT, EditorModes.TEXT_WITH_BINDING].includes(mode) && - str.includes(JS_FIELD_BEGIN) + ( + [EditorModes.JAVASCRIPT, EditorModes.TEXT_WITH_BINDING] as TEditorModes[] + ).includes(mode) && str.includes(JS_FIELD_BEGIN) ? JS_FIELD_BEGIN : nonWhitespace, ); @@ -127,7 +124,7 @@ function performLineCommenting( // we need to explicitly check if the SQL comment string is passed, make the mode SQL commentString === getLineCommentString(EditorModes.SQL) ? EditorModes.SQL - : (mode.name as EditorModes), + : (mode.name as TEditorModes), ), ); diff --git a/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx b/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx index dc1dc88ee8..b2d1e51b44 100644 --- a/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx +++ b/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx @@ -2,7 +2,10 @@ import React from "react"; import type { BaseFieldProps } from "redux-form"; import { Field } from "redux-form"; import type { EditorStyleProps } from "components/editorComponents/CodeEditor"; -import type { CodeEditorBorder } from "components/editorComponents/CodeEditor/EditorConfig"; +import type { + CodeEditorBorder, + TEditorModes, +} from "components/editorComponents/CodeEditor/EditorConfig"; import { EditorModes, EditorSize, @@ -16,7 +19,7 @@ class DynamicTextField extends React.Component< EditorStyleProps & { size?: EditorSize; tabBehaviour?: TabBehaviour; - mode?: EditorModes; + mode?: TEditorModes; theme?: EditorTheme; hoverInteraction?: boolean; border?: CodeEditorBorder; diff --git a/app/client/src/components/formControls/DynamicTextFieldControl.tsx b/app/client/src/components/formControls/DynamicTextFieldControl.tsx index 892a7a1491..0e45e0ae50 100644 --- a/app/client/src/components/formControls/DynamicTextFieldControl.tsx +++ b/app/client/src/components/formControls/DynamicTextFieldControl.tsx @@ -13,9 +13,13 @@ import { import { QUERY_EDITOR_FORM_NAME } from "@appsmith/constants/forms"; import type { AppState } from "@appsmith/reducers"; import styled from "styled-components"; -import { getPluginResponseTypes } from "selectors/entitiesSelector"; +import { + getPluginResponseTypes, + getPluginNameFromId, +} from "selectors/entitiesSelector"; import { actionPathFromName } from "components/formControls/utils"; import type { EvaluationSubstitutionType } from "entities/DataTree/dataTreeFactory"; +import { getSqlEditorModeFromPluginName } from "components/editorComponents/CodeEditor/sql/config"; const Wrapper = styled.div` min-width: 380px; @@ -59,12 +63,13 @@ class DynamicTextControl extends BaseControl< configProperty, evaluationSubstitutionType, placeholderText, + pluginName, responseType, } = this.props; const dataTreePath = actionPathFromName(actionName, configProperty); const mode = responseType === "TABLE" - ? EditorModes.SQL_WITH_BINDING + ? getSqlEditorModeFromPluginName(pluginName) : EditorModes.JSON_WITH_BINDING; return ( @@ -94,6 +99,7 @@ export interface DynamicTextFieldProps extends ControlProps { placeholderText?: string; evaluationSubstitutionType: EvaluationSubstitutionType; mutedHinting?: boolean; + pluginName: string; } const mapStateToProps = (state: AppState, props: DynamicTextFieldProps) => { @@ -103,11 +109,13 @@ const mapStateToProps = (state: AppState, props: DynamicTextFieldProps) => { const actionName = valueSelector(state, "name"); const pluginId = valueSelector(state, "datasource.pluginId"); const responseTypes = getPluginResponseTypes(state); + const pluginName = getPluginNameFromId(state, pluginId); return { actionName, pluginId, responseType: responseTypes[pluginId], + pluginName, }; }; diff --git a/app/client/src/globalStyles/CodemirrorHintStyles.ts b/app/client/src/globalStyles/CodemirrorHintStyles.ts index 414d4ff62a..435a4280fc 100644 --- a/app/client/src/globalStyles/CodemirrorHintStyles.ts +++ b/app/client/src/globalStyles/CodemirrorHintStyles.ts @@ -287,4 +287,23 @@ export const CodemirrorHintStyles = createGlobalStyle<{ background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAB1JREFUGFdjZICC/3sY/jO6MDAygvgwDpiGcWAqASvpC745SEL8AAAAAElFTkSuQmCC"); } } + .sql-hint-label{ + color: #6D6D6D; + } + + .CodeMirror-hint:hover{ + .sql-hint-label{ + color: #090707; + } + } + .CodeMirror-hint-active{ + .sql-hint-label{ + color: #fff + } + } + .CodeMirror-hint-active:hover{ + .sql-hint-label{ + color: #fff + } + } `; diff --git a/app/client/src/selectors/entitiesSelector.ts b/app/client/src/selectors/entitiesSelector.ts index d90a401cf7..c7c70225db 100644 --- a/app/client/src/selectors/entitiesSelector.ts +++ b/app/client/src/selectors/entitiesSelector.ts @@ -40,6 +40,7 @@ import { import { InstallState } from "reducers/uiReducers/libraryReducer"; import recommendedLibraries from "pages/Editor/Explorer/Libraries/recommendedLibraries"; import type { TJSLibrary } from "workers/common/JSLibrary"; +import { getEntityNameAndPropertyPath } from "@appsmith/workers/Evaluation/evaluationUtils"; export const getEntities = (state: AppState): AppState["entities"] => state.entities; @@ -1004,3 +1005,35 @@ export const selectJSCollectionByName = (collectionName: string) => (collection) => collection.config.name === collectionName, ); }); + +export const getAllDatasourceTableKeys = createSelector( + (state: AppState) => getDatasourcesStructure(state), + (state: AppState) => getActions(state), + (state: AppState, dataTreePath: string | undefined) => dataTreePath, + ( + datasourceStructures: ReturnType, + actions: ReturnType, + dataTreePath: string | undefined, + ) => { + if (!dataTreePath || !datasourceStructures) return; + const { entityName } = getEntityNameAndPropertyPath(dataTreePath); + const action = find(actions, ({ config: { name } }) => name === entityName); + if (!action) return; + const datasource = action.config.datasource; + const datasourceId = "id" in datasource ? datasource.id : undefined; + if (!datasourceId || !(datasourceId in datasourceStructures)) return; + const tables: Record = {}; + const { tables: datasourceTable } = datasourceStructures[datasourceId]; + if (!datasourceTable) return; + datasourceTable.forEach((table) => { + if (table?.name) { + tables[table.name] = "table"; + table.columns.forEach((column) => { + tables[`${table.name}.${column.name}`] = column.type; + }); + } + }); + + return tables; + }, +); diff --git a/app/yarn.lock b/app/yarn.lock index 0187fab361..b937c7150d 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -8922,7 +8922,7 @@ __metadata: chalk: ^4.1.1 classnames: ^2.3.1 clsx: ^1.2.1 - codemirror: ^5.59.2 + codemirror: ^5.65.13 codemirror-graphql: ^1.2.14 compression-webpack-plugin: ^10.0.0 copy-to-clipboard: ^3.3.1 @@ -11394,10 +11394,10 @@ __metadata: languageName: node linkType: hard -"codemirror@npm:^5.59.2": - version: 5.59.2 - resolution: "codemirror@npm:5.59.2" - checksum: d9e49082fcfa26f4ff7be61d2b9df004c9ad3c61cc82b317283a92881b112aa52d67cce996bd31f38fe02f8c873bbbb0dc2614da05b34e7e36deeae285ff562e +"codemirror@npm:^5.65.13": + version: 5.65.13 + resolution: "codemirror@npm:5.65.13" + checksum: 47060461edaebecd03b3fba4e73a30cdccc0c51ce3a3a05bafae3c9cafd682101383e94d77d54081eaf1ae18da5b74343e98343c637c52cea409956469039098 languageName: node linkType: hard