You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ju...@apache.org on 2023/06/08 19:53:25 UTC

[superset] branch master updated: chore(sqllab): Remove validation result from state (#24082)

This is an automated email from the ASF dual-hosted git repository.

justinpark pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new c4242a3657 chore(sqllab): Remove validation result from state (#24082)
c4242a3657 is described below

commit c4242a36572f0468766bad5e645f532d585af642
Author: JUST.in DO IT <ju...@airbnb.com>
AuthorDate: Thu Jun 8 12:53:16 2023 -0700

    chore(sqllab): Remove validation result from state (#24082)
---
 superset-frontend/src/SqlLab/actions/sqlLab.js     |  61 -------
 .../SqlLab/components/AceEditorWrapper/index.tsx   |  26 +--
 .../AceEditorWrapper/useAnnotations.test.ts        | 182 +++++++++++++++++++++
 .../components/AceEditorWrapper/useAnnotations.ts  |  83 ++++++++++
 .../src/SqlLab/components/SqlEditor/index.jsx      |  32 ----
 superset-frontend/src/SqlLab/reducers/sqlLab.js    |  79 ---------
 superset-frontend/src/SqlLab/types.ts              |   5 -
 superset-frontend/src/hooks/apiResources/index.ts  |   1 +
 .../src/hooks/apiResources/queryApi.ts             |   2 +-
 .../hooks/apiResources/queryValidations.test.ts    | 115 +++++++++++++
 .../src/hooks/apiResources/queryValidations.ts     |  66 ++++++++
 11 files changed, 457 insertions(+), 195 deletions(-)

diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js
index cb7a7e49b7..708f9a3346 100644
--- a/superset-frontend/src/SqlLab/actions/sqlLab.js
+++ b/superset-frontend/src/SqlLab/actions/sqlLab.js
@@ -84,9 +84,6 @@ export const CLEAR_QUERY_RESULTS = 'CLEAR_QUERY_RESULTS';
 export const REMOVE_DATA_PREVIEW = 'REMOVE_DATA_PREVIEW';
 export const CHANGE_DATA_PREVIEW_ID = 'CHANGE_DATA_PREVIEW_ID';
 
-export const START_QUERY_VALIDATION = 'START_QUERY_VALIDATION';
-export const QUERY_VALIDATION_RETURNED = 'QUERY_VALIDATION_RETURNED';
-export const QUERY_VALIDATION_FAILED = 'QUERY_VALIDATION_FAILED';
 export const COST_ESTIMATE_STARTED = 'COST_ESTIMATE_STARTED';
 export const COST_ESTIMATE_RETURNED = 'COST_ESTIMATE_RETURNED';
 export const COST_ESTIMATE_FAILED = 'COST_ESTIMATE_FAILED';
@@ -139,21 +136,6 @@ export function resetState() {
   return { type: RESET_STATE };
 }
 
-export function startQueryValidation(query) {
-  Object.assign(query, {
-    id: query.id ? query.id : shortid.generate(),
-  });
-  return { type: START_QUERY_VALIDATION, query };
-}
-
-export function queryValidationReturned(query, results) {
-  return { type: QUERY_VALIDATION_RETURNED, query, results };
-}
-
-export function queryValidationFailed(query, message, error) {
-  return { type: QUERY_VALIDATION_FAILED, query, message, error };
-}
-
 export function updateQueryEditor(alterations) {
   return { type: UPDATE_QUERY_EDITOR, alterations };
 }
@@ -440,49 +422,6 @@ export function reRunQuery(query) {
   };
 }
 
-export function validateQuery(queryEditor, sql) {
-  return function (dispatch, getState) {
-    const {
-      sqlLab: { unsavedQueryEditor },
-    } = getState();
-    const qe = {
-      ...queryEditor,
-      ...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
-    };
-
-    const query = {
-      dbId: qe.dbId,
-      sql,
-      sqlEditorId: qe.id,
-      schema: qe.schema,
-      templateParams: qe.templateParams,
-    };
-    dispatch(startQueryValidation(query));
-
-    const postPayload = {
-      schema: query.schema,
-      sql: query.sql,
-      template_params: query.templateParams,
-    };
-
-    return SupersetClient.post({
-      endpoint: `/api/v1/database/${query.dbId}/validate_sql/`,
-      body: JSON.stringify(postPayload),
-      headers: { 'Content-Type': 'application/json' },
-    })
-      .then(({ json }) => dispatch(queryValidationReturned(query, json.result)))
-      .catch(response =>
-        getClientErrorObject(response.result).then(error => {
-          let message = error.error || error.statusText || t('Unknown error');
-          if (message.includes('CSRF token')) {
-            message = t(COMMON_ERR_MESSAGES.SESSION_TIMED_OUT);
-          }
-          dispatch(queryValidationFailed(query, message, error));
-        }),
-      );
-  };
-}
-
 export function postStopQuery(query) {
   return function (dispatch) {
     return SupersetClient.post({
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
index a89c46506f..5f7d92e943 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
@@ -41,6 +41,7 @@ import {
 import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
 import { useSchemas, useTables } from 'src/hooks/apiResources';
 import { useDatabaseFunctionsQuery } from 'src/hooks/apiResources/databaseFunctions';
+import { useAnnotations } from './useAnnotations';
 
 type HotKey = {
   key: string;
@@ -96,8 +97,8 @@ const AceEditorWrapper = ({
     'id',
     'dbId',
     'sql',
-    'validationResult',
     'schema',
+    'templateParams',
   ]);
   const { data: schemaOptions } = useSchemas({
     ...(autocomplete && { dbId: queryEditor.dbId }),
@@ -286,21 +287,12 @@ const AceEditorWrapper = ({
 
     setWords(words);
   }
-
-  const getAceAnnotations = () => {
-    const { validationResult } = queryEditor;
-    const resultIsReady = validationResult?.completed;
-    if (resultIsReady && validationResult?.errors?.length) {
-      const errors = validationResult.errors.map((err: any) => ({
-        type: 'error',
-        row: err.line_number - 1,
-        column: err.start_column - 1,
-        text: err.message,
-      }));
-      return errors;
-    }
-    return [];
-  };
+  const { data: annotations } = useAnnotations({
+    dbId: queryEditor.dbId,
+    schema: queryEditor.schema,
+    sql: currentSql,
+    templateParams: queryEditor.templateParams,
+  });
 
   return (
     <StyledAceEditor
@@ -313,7 +305,7 @@ const AceEditorWrapper = ({
       editorProps={{ $blockScrolling: true }}
       enableLiveAutocompletion={autocomplete}
       value={sql}
-      annotations={getAceAnnotations()}
+      annotations={annotations}
     />
   );
 };
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.test.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.test.ts
new file mode 100644
index 0000000000..ddabbea55b
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.test.ts
@@ -0,0 +1,182 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import fetchMock from 'fetch-mock';
+import { act, renderHook } from '@testing-library/react-hooks';
+import {
+  createWrapper,
+  defaultStore as store,
+} from 'spec/helpers/testing-library';
+import { api } from 'src/hooks/apiResources/queryApi';
+import { initialState } from 'src/SqlLab/fixtures';
+import COMMON_ERR_MESSAGES from 'src/utils/errorMessages';
+import { useAnnotations } from './useAnnotations';
+
+const fakeApiResult = {
+  result: [
+    {
+      end_column: null,
+      line_number: 3,
+      message: 'ERROR: syntax error at or near ";"',
+      start_column: null,
+    },
+  ],
+};
+const expectDbId = 'db1';
+const expectSchema = 'my_schema';
+const expectSql = 'SELECT * from example_table';
+const expectTemplateParams = '{"a": 1, "v": "str"}';
+const expectValidatorEngine = 'defined_validator';
+const queryValidationApiRoute = `glob:*/api/v1/database/${expectDbId}/validate_sql/`;
+
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  t: (str: string) => str,
+}));
+
+afterEach(() => {
+  fetchMock.reset();
+  act(() => {
+    store.dispatch(api.util.resetApiState());
+  });
+});
+
+beforeEach(() => {
+  fetchMock.post(queryValidationApiRoute, fakeApiResult);
+});
+
+const initialize = (withValidator = false) => {
+  if (withValidator) {
+    return renderHook(
+      () =>
+        useAnnotations({
+          sql: expectSql,
+          dbId: expectDbId,
+          schema: expectSchema,
+          templateParams: expectTemplateParams,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          initialState: {
+            ...initialState,
+            sqlLab: {
+              ...initialState.sqlLab,
+              databases: {
+                [expectDbId]: {
+                  backend: expectValidatorEngine,
+                },
+              },
+            },
+            common: {
+              conf: {
+                SQL_VALIDATORS_BY_ENGINE: {
+                  [expectValidatorEngine]: true,
+                },
+              },
+            },
+          },
+        }),
+      },
+    );
+  }
+  return renderHook(
+    () =>
+      useAnnotations({
+        sql: expectSql,
+        dbId: expectDbId,
+        schema: expectSchema,
+        templateParams: expectTemplateParams,
+      }),
+    {
+      wrapper: createWrapper({
+        useRedux: true,
+        store,
+      }),
+    },
+  );
+};
+
+test('skips fetching validation if validator is undefined', () => {
+  const { result } = initialize();
+  expect(result.current.data).toEqual([]);
+  expect(fetchMock.calls(queryValidationApiRoute)).toHaveLength(0);
+});
+
+test('returns validation if validator is configured', async () => {
+  const { result, waitFor } = initialize(true);
+  await waitFor(() =>
+    expect(fetchMock.calls(queryValidationApiRoute)).toHaveLength(1),
+  );
+  expect(result.current.data).toEqual(
+    fakeApiResult.result.map(err => ({
+      type: 'error',
+      row: (err.line_number || 0) - 1,
+      column: (err.start_column || 0) - 1,
+      text: err.message,
+    })),
+  );
+});
+
+test('returns server error description', async () => {
+  const errorMessage = 'Unexpected validation api error';
+  fetchMock.post(
+    queryValidationApiRoute,
+    {
+      throws: new Error(errorMessage),
+    },
+    { overwriteRoutes: true },
+  );
+  const { result, waitFor } = initialize(true);
+  await waitFor(
+    () =>
+      expect(result.current.data).toEqual([
+        {
+          type: 'error',
+          row: 0,
+          column: 0,
+          text: `The server failed to validate your query.\n${errorMessage}`,
+        },
+      ]),
+    { timeout: 5000 },
+  );
+});
+
+test('returns sesion expire description when CSRF token expired', async () => {
+  const errorMessage = 'CSRF token expired';
+  fetchMock.post(
+    queryValidationApiRoute,
+    {
+      throws: new Error(errorMessage),
+    },
+    { overwriteRoutes: true },
+  );
+  const { result, waitFor } = initialize(true);
+  await waitFor(
+    () =>
+      expect(result.current.data).toEqual([
+        {
+          type: 'error',
+          row: 0,
+          column: 0,
+          text: `The server failed to validate your query.\n${COMMON_ERR_MESSAGES.SESSION_TIMED_OUT}`,
+        },
+      ]),
+    { timeout: 5000 },
+  );
+});
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.ts
new file mode 100644
index 0000000000..80c25706ab
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.ts
@@ -0,0 +1,83 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useSelector } from 'react-redux';
+
+import { SqlLabRootState } from 'src/SqlLab/types';
+import COMMON_ERR_MESSAGES from 'src/utils/errorMessages';
+import { VALIDATION_DEBOUNCE_MS } from 'src/SqlLab/constants';
+import {
+  FetchValidationQueryParams,
+  useQueryValidationsQuery,
+} from 'src/hooks/apiResources';
+import { useDebounceValue } from 'src/hooks/useDebounceValue';
+import { ClientErrorObject } from 'src/utils/getClientErrorObject';
+import { t } from '@superset-ui/core';
+
+export function useAnnotations(params: FetchValidationQueryParams) {
+  const { sql, dbId, schema, templateParams } = params;
+  const debouncedSql = useDebounceValue(sql, VALIDATION_DEBOUNCE_MS);
+  const hasValidator = useSelector<SqlLabRootState>(({ sqlLab, common }) =>
+    // Check whether or not we can validate the current query based on whether
+    // or not the backend has a validator configured for it.
+    Boolean(
+      common?.conf?.SQL_VALIDATORS_BY_ENGINE?.[
+        sqlLab?.databases?.[dbId || '']?.backend
+      ],
+    ),
+  );
+  return useQueryValidationsQuery(
+    {
+      dbId,
+      schema,
+      sql: debouncedSql,
+      templateParams,
+    },
+    {
+      skip: !(hasValidator && dbId && sql),
+      selectFromResult: ({ isLoading, isError, error, data }) => {
+        const errorObj = (error ?? {}) as ClientErrorObject;
+        let message =
+          errorObj?.error || errorObj?.statusText || t('Unknown error');
+        if (message.includes('CSRF token')) {
+          message = t(COMMON_ERR_MESSAGES.SESSION_TIMED_OUT);
+        }
+        return {
+          data:
+            !isLoading && data?.length
+              ? data.map(err => ({
+                  type: 'error',
+                  row: (err.line_number || 0) - 1,
+                  column: (err.start_column || 0) - 1,
+                  text: err.message,
+                }))
+              : isError
+              ? [
+                  {
+                    type: 'error',
+                    row: 0,
+                    column: 0,
+                    text: `The server failed to validate your query.\n${message}`,
+                  },
+                ]
+              : [],
+        };
+      },
+    },
+  );
+}
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
index 0216ed2f5e..6399baa1cd 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
@@ -66,7 +66,6 @@ import {
   scheduleQuery,
   setActiveSouthPaneTab,
   updateSavedQuery,
-  validateQuery,
 } from 'src/SqlLab/actions/sqlLab';
 import {
   STATE_TYPE_MAP,
@@ -78,7 +77,6 @@ import {
   INITIAL_NORTH_PERCENT,
   INITIAL_SOUTH_PERCENT,
   SET_QUERY_EDITOR_SQL_DEBOUNCE_MS,
-  VALIDATION_DEBOUNCE_MS,
   WINDOW_RESIZE_THROTTLE_MS,
 } from 'src/SqlLab/constants';
 import {
@@ -102,8 +100,6 @@ import RunQueryActionButton from '../RunQueryActionButton';
 import QueryLimitSelect from '../QueryLimitSelect';
 
 const bootstrapData = getBootstrapData();
-const validatorMap =
-  bootstrapData?.common?.conf?.SQL_VALIDATORS_BY_ENGINE || {};
 const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
 
 const StyledToolbar = styled.div`
@@ -437,37 +433,9 @@ const SqlEditor = ({
     [setQueryEditorAndSaveSql],
   );
 
-  const canValidateQuery = () => {
-    // Check whether or not we can validate the current query based on whether
-    // or not the backend has a validator configured for it.
-    if (database) {
-      return validatorMap.hasOwnProperty(database.backend);
-    }
-    return false;
-  };
-
-  const requestValidation = useCallback(
-    sql => {
-      if (database) {
-        dispatch(validateQuery(queryEditor, sql));
-      }
-    },
-    [database, dispatch, queryEditor],
-  );
-
-  const requestValidationWithDebounce = useMemo(
-    () => debounce(requestValidation, VALIDATION_DEBOUNCE_MS),
-    [requestValidation],
-  );
-
   const onSqlChanged = sql => {
     dispatch(queryEditorSetSql(queryEditor, sql));
     setQueryEditorAndSaveSqlWithDebounce(sql);
-    // Request server-side validation of the query text
-    if (canValidateQuery()) {
-      // NB. requestValidation is debounced
-      requestValidationWithDebounce(sql);
-    }
   };
 
   // Return the heights for the ace editor and the south pane as an object
diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.js b/superset-frontend/src/SqlLab/reducers/sqlLab.js
index 6ff81f03da..6dcd07a77b 100644
--- a/superset-frontend/src/SqlLab/reducers/sqlLab.js
+++ b/superset-frontend/src/SqlLab/reducers/sqlLab.js
@@ -236,85 +236,6 @@ export default function sqlLabReducer(state = {}, action) {
         tables: state.tables.filter(table => !tableIds.includes(table.id)),
       };
     },
-    [actions.START_QUERY_VALIDATION]() {
-      return {
-        ...state,
-        ...alterUnsavedQueryEditorState(
-          state,
-          {
-            validationResult: {
-              id: action.query.id,
-              errors: [],
-              completed: false,
-            },
-          },
-          action.query.sqlEditorId,
-        ),
-      };
-    },
-    [actions.QUERY_VALIDATION_RETURNED]() {
-      // If the server is very slow about answering us, we might get validation
-      // responses back out of order. This check confirms the response we're
-      // handling corresponds to the most recently dispatched request.
-      //
-      // We don't care about any but the most recent because validations are
-      // only valid for the SQL text they correspond to -- once the SQL has
-      // changed, the old validation doesn't tell us anything useful anymore.
-      const qe = {
-        ...getFromArr(state.queryEditors, action.query.sqlEditorId),
-        ...(state.unsavedQueryEditor.id === action.query.sqlEditorId &&
-          state.unsavedQueryEditor),
-      };
-      if (qe.validationResult.id !== action.query.id) {
-        return state;
-      }
-      // Otherwise, persist the results on the queryEditor state
-      return {
-        ...state,
-        ...alterUnsavedQueryEditorState(
-          state,
-          {
-            validationResult: {
-              id: action.query.id,
-              errors: action.results,
-              completed: true,
-            },
-          },
-          action.query.sqlEditorId,
-        ),
-      };
-    },
-    [actions.QUERY_VALIDATION_FAILED]() {
-      // If the server is very slow about answering us, we might get validation
-      // responses back out of order. This check confirms the response we're
-      // handling corresponds to the most recently dispatched request.
-      //
-      // We don't care about any but the most recent because validations are
-      // only valid for the SQL text they correspond to -- once the SQL has
-      // changed, the old validation doesn't tell us anything useful anymore.
-      const qe = getFromArr(state.queryEditors, action.query.sqlEditorId);
-      if (qe.validationResult.id !== action.query.id) {
-        return state;
-      }
-      // Otherwise, persist the results on the queryEditor state
-      let newState = { ...state };
-      const sqlEditor = { id: action.query.sqlEditorId };
-      newState = alterInArr(newState, 'queryEditors', sqlEditor, {
-        validationResult: {
-          id: action.query.id,
-          errors: [
-            {
-              line_number: 1,
-              start_column: 1,
-              end_column: 1,
-              message: `The server failed to validate your query.\n${action.message}`,
-            },
-          ],
-          completed: true,
-        },
-      });
-      return newState;
-    },
     [actions.COST_ESTIMATE_STARTED]() {
       return {
         ...state,
diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts
index f7ab930b43..b1a8812471 100644
--- a/superset-frontend/src/SqlLab/types.ts
+++ b/superset-frontend/src/SqlLab/types.ts
@@ -17,7 +17,6 @@
  * under the License.
  */
 import { JsonObject, QueryResponse } from '@superset-ui/core';
-import { SupersetError } from 'src/components/ErrorMessage/types';
 import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
 import { ToastType } from 'src/components/MessageToasts/types';
 import { RootState } from 'src/dashboard/types';
@@ -39,10 +38,6 @@ export interface QueryEditor {
   autorun: boolean;
   sql: string;
   remoteId: number | null;
-  validationResult?: {
-    completed: boolean;
-    errors: SupersetError[];
-  };
   hideLeftBar?: boolean;
   latestQueryId?: string | null;
   templateParams?: string;
diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/hooks/apiResources/index.ts
index 81d77b5d11..dbc9882258 100644
--- a/superset-frontend/src/hooks/apiResources/index.ts
+++ b/superset-frontend/src/hooks/apiResources/index.ts
@@ -30,3 +30,4 @@ export * from './charts';
 export * from './dashboards';
 export * from './tables';
 export * from './schemas';
+export * from './queryValidations';
diff --git a/superset-frontend/src/hooks/apiResources/queryApi.ts b/superset-frontend/src/hooks/apiResources/queryApi.ts
index ee21227b29..159e6095f1 100644
--- a/superset-frontend/src/hooks/apiResources/queryApi.ts
+++ b/superset-frontend/src/hooks/apiResources/queryApi.ts
@@ -65,7 +65,7 @@ export const supersetClientQuery: BaseQueryFn<
 
 export const api = createApi({
   reducerPath: 'queryApi',
-  tagTypes: ['Schemas', 'Tables', 'DatabaseFunctions'],
+  tagTypes: ['Schemas', 'Tables', 'DatabaseFunctions', 'QueryValidations'],
   endpoints: () => ({}),
   baseQuery: supersetClientQuery,
 });
diff --git a/superset-frontend/src/hooks/apiResources/queryValidations.test.ts b/superset-frontend/src/hooks/apiResources/queryValidations.test.ts
new file mode 100644
index 0000000000..f1f1f4eb4a
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/queryValidations.test.ts
@@ -0,0 +1,115 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import fetchMock from 'fetch-mock';
+import { act, renderHook } from '@testing-library/react-hooks';
+import {
+  createWrapper,
+  defaultStore as store,
+} from 'spec/helpers/testing-library';
+import { api } from 'src/hooks/apiResources/queryApi';
+import { useQueryValidationsQuery } from './queryValidations';
+
+const fakeApiResult = {
+  result: [
+    {
+      end_column: null,
+      line_number: 3,
+      message: 'ERROR: syntax error at or near ";"',
+      start_column: null,
+    },
+  ],
+};
+
+const expectedResult = fakeApiResult.result;
+
+const expectDbId = 'db1';
+const expectSchema = 'my_schema';
+const expectSql = 'SELECT * from example_table';
+const expectTemplateParams = '{"a": 1, "v": "str"}';
+
+afterEach(() => {
+  fetchMock.reset();
+  act(() => {
+    store.dispatch(api.util.resetApiState());
+  });
+});
+
+test('returns api response mapping json result', async () => {
+  const queryValidationApiRoute = `glob:*/api/v1/database/${expectDbId}/validate_sql/`;
+  fetchMock.post(queryValidationApiRoute, fakeApiResult);
+  const { result, waitFor } = renderHook(
+    () =>
+      useQueryValidationsQuery({
+        dbId: expectDbId,
+        sql: expectSql,
+        schema: expectSchema,
+        templateParams: expectTemplateParams,
+      }),
+    {
+      wrapper: createWrapper({
+        useRedux: true,
+        store,
+      }),
+    },
+  );
+  await waitFor(() =>
+    expect(fetchMock.calls(queryValidationApiRoute).length).toBe(1),
+  );
+  expect(result.current.data).toEqual(expectedResult);
+  expect(fetchMock.calls(queryValidationApiRoute).length).toBe(1);
+  expect(
+    JSON.parse(`${fetchMock.calls(queryValidationApiRoute)[0][1]?.body}`),
+  ).toEqual({
+    schema: expectSchema,
+    sql: expectSql,
+    template_params: JSON.parse(expectTemplateParams),
+  });
+  act(() => {
+    result.current.refetch();
+  });
+  await waitFor(() =>
+    expect(fetchMock.calls(queryValidationApiRoute).length).toBe(2),
+  );
+  expect(result.current.data).toEqual(expectedResult);
+});
+
+test('returns cached data without api request', async () => {
+  const queryValidationApiRoute = `glob:*/api/v1/database/${expectDbId}/validate_sql/`;
+  fetchMock.post(queryValidationApiRoute, fakeApiResult);
+  const { result, waitFor, rerender } = renderHook(
+    () =>
+      useQueryValidationsQuery({
+        dbId: expectDbId,
+        sql: expectSql,
+        schema: expectSchema,
+        templateParams: expectTemplateParams,
+      }),
+    {
+      wrapper: createWrapper({
+        useRedux: true,
+        store,
+      }),
+    },
+  );
+  await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+  expect(fetchMock.calls(queryValidationApiRoute).length).toBe(1);
+  rerender();
+  await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+  expect(fetchMock.calls(queryValidationApiRoute).length).toBe(1);
+});
diff --git a/superset-frontend/src/hooks/apiResources/queryValidations.ts b/superset-frontend/src/hooks/apiResources/queryValidations.ts
new file mode 100644
index 0000000000..722c320049
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/queryValidations.ts
@@ -0,0 +1,66 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { api, JsonResponse } from './queryApi';
+
+export type FetchValidationQueryParams = {
+  dbId?: string | number;
+  schema?: string;
+  sql: string;
+  templateParams?: string;
+};
+
+type ValidationResult = {
+  end_column: number | null;
+  line_number: number | null;
+  message: string | null;
+  start_column: number | null;
+};
+
+const queryValidationApi = api.injectEndpoints({
+  endpoints: builder => ({
+    queryValidations: builder.query<
+      ValidationResult[],
+      FetchValidationQueryParams
+    >({
+      providesTags: ['QueryValidations'],
+      query: ({ dbId, schema, sql, templateParams }) => {
+        let template_params = templateParams;
+        try {
+          template_params = JSON.parse(templateParams || '');
+        } catch (e) {
+          template_params = undefined;
+        }
+        const postPayload = {
+          schema,
+          sql,
+          ...(template_params && { template_params }),
+        };
+        return {
+          method: 'post',
+          endpoint: `/api/v1/database/${dbId}/validate_sql/`,
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify(postPayload),
+          transformResponse: ({ json }: JsonResponse) => json.result,
+        };
+      },
+    }),
+  }),
+});
+
+export const { useQueryValidationsQuery } = queryValidationApi;