/** * 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 { nanoid } from 'nanoid'; import rison from 'rison'; import type { AnyAction } from 'redux'; import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import type { QueryColumn, SupersetError } from '@superset-ui/core'; import { FeatureFlag, SupersetClient, isFeatureEnabled, COMMON_ERR_MESSAGES, getClientErrorObject, } from '@superset-ui/core'; import { t } from '@apache-superset/core/translation'; import { invert, mapKeys } from 'lodash'; import { now } from '@superset-ui/core/utils/dates'; import { addDangerToast as addDangerToastAction, addInfoToast as addInfoToastAction, addSuccessToast as addSuccessToastAction, addWarningToast as addWarningToastAction, } from 'src/components/MessageToasts/actions'; import { LOG_ACTIONS_SQLLAB_FETCH_FAILED_QUERY } from 'src/logger/LogUtils'; import type { BootstrapData } from 'src/types/bootstrapTypes'; import getBootstrapData from 'src/utils/getBootstrapData'; import { logEvent } from 'src/logger/actions'; import type { QueryEditor, SqlLabRootState, Table } from '../types'; import { newQueryTabName } from '../utils/newQueryTabName'; import getInitialState from '../reducers/getInitialState'; import { rehydratePersistedState } from '../utils/reduxStateToLocalStorageHelper'; import { PREVIEW_QUERY_LIMIT } from '../constants'; import { EMPTY_STATE_QE_ID } from '../hooks/useQueryEditor'; // Type definitions for SqlLab actions export interface Query { id: string; dbId?: number; sql: string; sqlEditorId?: string | null; sqlEditorImmutableId?: string; tab?: string | null; catalog?: string | null; schema?: string | null; tempTable?: string | null; templateParams?: string; queryLimit?: number; runAsync?: boolean; ctas?: boolean; ctas_method?: string; isDataPreview?: boolean; progress?: number; startDttm?: number; endDttm?: number; state?: string; cached?: boolean; resultsKey?: string | null; updateTabState?: boolean; tableName?: string; link?: string; inLocalStorage?: boolean; executedSql?: string; query_id?: number; } export interface Database { id: number; allow_run_async: boolean; disable_data_preview?: boolean; } /** * Query result data from the SQL execute API (matches QueryResultSchema) */ export interface SqlExecuteQueryResult { endDttm?: number; executedSql?: string; limit?: number; limitingFactor?: string; tempTable?: string | null; progress?: number; state?: string; [key: string]: unknown; } /** * Response from /api/v1/sqllab/execute/ * This matches QueryExecutionResponseSchema from the backend */ export interface SqlExecuteResponse { status?: string; data: Record[]; columns: QueryColumn[]; selected_columns?: QueryColumn[]; expanded_columns?: QueryColumn[]; query: SqlExecuteQueryResult; query_id?: number; } export const RESET_STATE = 'RESET_STATE'; export const ADD_QUERY_EDITOR = 'ADD_QUERY_EDITOR'; export const UPDATE_QUERY_EDITOR = 'UPDATE_QUERY_EDITOR'; export const QUERY_EDITOR_SAVED = 'QUERY_EDITOR_SAVED'; export const CLONE_QUERY_TO_NEW_TAB = 'CLONE_QUERY_TO_NEW_TAB'; export const REMOVE_QUERY_EDITOR = 'REMOVE_QUERY_EDITOR'; export const MERGE_TABLE = 'MERGE_TABLE'; export const REMOVE_TABLES = 'REMOVE_TABLES'; export const END_QUERY = 'END_QUERY'; export const REMOVE_QUERY = 'REMOVE_QUERY'; export const EXPAND_TABLE = 'EXPAND_TABLE'; export const COLLAPSE_TABLE = 'COLLAPSE_TABLE'; export const QUERY_EDITOR_SETDB = 'QUERY_EDITOR_SETDB'; export const QUERY_EDITOR_SET_CATALOG = 'QUERY_EDITOR_SET_CATALOG'; export const QUERY_EDITOR_SET_SCHEMA = 'QUERY_EDITOR_SET_SCHEMA'; export const QUERY_EDITOR_SET_TITLE = 'QUERY_EDITOR_SET_TITLE'; export const QUERY_EDITOR_SET_AUTORUN = 'QUERY_EDITOR_SET_AUTORUN'; export const QUERY_EDITOR_SET_SQL = 'QUERY_EDITOR_SET_SQL'; export const QUERY_EDITOR_SET_CURSOR_POSITION = 'QUERY_EDITOR_SET_CURSOR_POSITION'; export const QUERY_EDITOR_SET_QUERY_LIMIT = 'QUERY_EDITOR_SET_QUERY_LIMIT'; export const QUERY_EDITOR_SET_TEMPLATE_PARAMS = 'QUERY_EDITOR_SET_TEMPLATE_PARAMS'; export const QUERY_EDITOR_SET_SELECTED_TEXT = 'QUERY_EDITOR_SET_SELECTED_TEXT'; export const QUERY_EDITOR_SET_FUNCTION_NAMES = 'QUERY_EDITOR_SET_FUNCTION_NAMES'; export const QUERY_EDITOR_PERSIST_HEIGHT = 'QUERY_EDITOR_PERSIST_HEIGHT'; export const QUERY_EDITOR_TOGGLE_LEFT_BAR = 'QUERY_EDITOR_TOGGLE_LEFT_BAR'; export const MIGRATE_QUERY_EDITOR = 'MIGRATE_QUERY_EDITOR'; export const MIGRATE_TABLE = 'MIGRATE_TABLE'; export const MIGRATE_QUERY = 'MIGRATE_QUERY'; export const SET_DATABASES = 'SET_DATABASES'; export const SET_ACTIVE_QUERY_EDITOR = 'SET_ACTIVE_QUERY_EDITOR'; export const LOAD_QUERY_EDITOR = 'LOAD_QUERY_EDITOR'; export const SET_TABLES = 'SET_TABLES'; export const SET_ACTIVE_SOUTHPANE_TAB = 'SET_ACTIVE_SOUTHPANE_TAB'; export const REFRESH_QUERIES = 'REFRESH_QUERIES'; export const SET_USER_OFFLINE = 'SET_USER_OFFLINE'; export const RUN_QUERY = 'RUN_QUERY'; export const START_QUERY = 'START_QUERY'; export const STOP_QUERY = 'STOP_QUERY'; export const REQUEST_QUERY_RESULTS = 'REQUEST_QUERY_RESULTS'; export const QUERY_SUCCESS = 'QUERY_SUCCESS'; export const QUERY_FAILED = 'QUERY_FAILED'; export const CLEAR_INACTIVE_QUERIES = 'CLEAR_INACTIVE_QUERIES'; 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 COST_ESTIMATE_STARTED = 'COST_ESTIMATE_STARTED'; export const COST_ESTIMATE_RETURNED = 'COST_ESTIMATE_RETURNED'; export const COST_ESTIMATE_FAILED = 'COST_ESTIMATE_FAILED'; export const CREATE_DATASOURCE_STARTED = 'CREATE_DATASOURCE_STARTED'; export const CREATE_DATASOURCE_SUCCESS = 'CREATE_DATASOURCE_SUCCESS'; export const CREATE_DATASOURCE_FAILED = 'CREATE_DATASOURCE_FAILED'; export const SET_EDITOR_TAB_LAST_UPDATE = 'SET_EDITOR_TAB_LAST_UPDATE'; export const SET_LAST_UPDATED_ACTIVE_TAB = 'SET_LAST_UPDATED_ACTIVE_TAB'; export const CLEAR_DESTROYED_QUERY_EDITOR = 'CLEAR_DESTROYED_QUERY_EDITOR'; // SqlLab action interface with optional properties for reducer compatibility. // Uses explicit property types instead of `any` for better type safety. export interface SqlLabAction { type: string; // Query editor related queryEditor?: QueryEditor | Partial; alterations?: Partial; oldQueryEditor?: QueryEditor; newQueryEditor?: QueryEditor; queryEditorId?: string; // Query related query?: Query | Partial; queries?: Query[]; alteredQueries?: Record>; queryId?: string; // Table related table?: Table | Partial; tables?: Table[]; oldTable?: Table; newTable?: Table; // Database related databases?: Database[] | Record; dbId?: number; // State and config sqlLabInitialState?: SqlLabRootState['sqlLab']; sql?: string | null; name?: string; catalog?: string | null; schema?: string | null; autorun?: boolean; templateParams?: string; queryLimit?: number; position?: { row: number; column: number }; functionNames?: string[]; northPercent?: number; southPercent?: number; hideLeftBar?: boolean; // Results and errors results?: SqlExecuteResponse; errors?: SupersetError[]; error?: string; msg?: string; link?: string; err?: string; // Misc tabId?: string | number; timestamp?: number; interval?: number; offline?: boolean; datasource?: unknown; clientId?: string; result?: Record; prepend?: boolean; json?: Record; oldQueryId?: string; newQuery?: { id: string }; } // Use AnyAction for ThunkAction/ThunkDispatch to maintain compatibility with // redux-mock-store and standard Redux patterns. SqlLabAction is used for plain // action creator return types where the shape is known. type SqlLabThunkAction = ThunkAction< R, SqlLabRootState, undefined, AnyAction >; type AppDispatch = ThunkDispatch; type GetState = () => SqlLabRootState; export const addInfoToast = addInfoToastAction; export const addSuccessToast = addSuccessToastAction; export const addDangerToast = addDangerToastAction; export const addWarningToast = addWarningToastAction; export const CtasEnum = { Table: 'TABLE', View: 'VIEW', } as const; const ERR_MSG_CANT_LOAD_QUERY = t("The query couldn't be loaded"); // a map of SavedQuery field names to the different names used client-side, // because for now making the names consistent is too complicated // so it might as well only happen in one place const queryClientMapping: Record = { id: 'remoteId', db_id: 'dbId', label: 'name', template_parameters: 'templateParams', }; const queryServerMapping = invert(queryClientMapping); // uses a mapping like those above to convert object key names to another style const fieldConverter = (mapping: Record) => >(obj: T): Record => mapKeys(obj, (_value, key) => (key in mapping ? mapping[key] : key)); export const convertQueryToServer = fieldConverter(queryServerMapping); export const convertQueryToClient = fieldConverter(queryClientMapping); export function getUpToDateQuery( rootState: SqlLabRootState, queryEditor: Partial, key?: string, ): QueryEditor { const { sqlLab: { unsavedQueryEditor, queryEditors }, } = rootState; const id = key ?? queryEditor.id; return { id, ...queryEditors.find((qe: QueryEditor) => qe.id === id), ...(id === unsavedQueryEditor.id && unsavedQueryEditor), } as QueryEditor; } export function resetState(data?: Record): SqlLabThunkAction { return (dispatch: AppDispatch, getState: GetState) => { const { common } = getState(); const initialState = getInitialState({ ...getBootstrapData(), common, ...(data as Partial), }); dispatch({ type: RESET_STATE, sqlLabInitialState: initialState.sqlLab as SqlLabRootState['sqlLab'], }); rehydratePersistedState( dispatch, initialState as unknown as SqlLabRootState, ); }; } export function updateQueryEditor( alterations: Partial, ): SqlLabAction { return { type: UPDATE_QUERY_EDITOR, alterations }; } export function setEditorTabLastUpdate(timestamp: number): SqlLabAction { return { type: SET_EDITOR_TAB_LAST_UPDATE, timestamp }; } export function scheduleQuery( query: Record, ): SqlLabThunkAction> { return (dispatch: AppDispatch) => SupersetClient.post({ endpoint: '/api/v1/saved_query/', jsonPayload: query, stringify: false, }) .then(() => dispatch( addSuccessToast( t( 'Your query has been scheduled. To see details of your query, navigate to Saved queries', ), ), ), ) .catch(() => dispatch(addDangerToast(t('Your query could not be scheduled'))), ); } export function estimateQueryCost( queryEditor: QueryEditor, ): SqlLabThunkAction> { return (dispatch: AppDispatch, getState: GetState) => { const { dbId, catalog, schema, sql, selectedText, templateParams } = getUpToDateQuery(getState(), queryEditor); const requestSql = selectedText || sql; const postPayload = { database_id: dbId, catalog, schema, sql: requestSql, template_params: JSON.parse(templateParams || '{}'), }; return Promise.all([ dispatch({ type: COST_ESTIMATE_STARTED, query: queryEditor }), SupersetClient.post({ endpoint: '/api/v1/sqllab/estimate/', body: JSON.stringify(postPayload), headers: { 'Content-Type': 'application/json' }, }) .then(({ json }) => dispatch({ type: COST_ESTIMATE_RETURNED, query: queryEditor, json }), ) .catch(response => getClientErrorObject(response).then(error => { const message = error.error || error.statusText || t('Failed at retrieving results'); return dispatch({ type: COST_ESTIMATE_FAILED, query: queryEditor, error: message, }); }), ), ]); }; } export function clearInactiveQueries(interval: number): SqlLabAction { return { type: CLEAR_INACTIVE_QUERIES, interval }; } export function startQuery(query: Query, runPreviewOnly?: boolean) { Object.assign(query, { id: query.id ? query.id : nanoid(11), progress: 0, startDttm: now(), state: 'pending', cached: false, }); return { type: START_QUERY, query, runPreviewOnly } as const; } export function querySuccess(query: Query, results: SqlExecuteResponse) { return { type: QUERY_SUCCESS, query, results } as const; } export function logFailedQuery( query: Query, errors?: SupersetError[], ): SqlLabThunkAction { return function (dispatch) { const eventData = { has_err: true, start_offset: query.startDttm, ts: new Date().getTime(), }; errors?.forEach(({ error_type: errorType, message, extra }) => { const issueCodes = ( extra as { issue_codes?: { code: number }[] } )?.issue_codes?.map(({ code }) => code) || [-1]; dispatch( logEvent(LOG_ACTIONS_SQLLAB_FETCH_FAILED_QUERY, { ...eventData, error_type: errorType, issue_codes: issueCodes, error_details: message, }), ); }); }; } export function createQueryFailedAction( query: Query, msg: string, link?: string, errors?: SupersetError[], ) { return { type: QUERY_FAILED, query, msg, link, errors } as const; } export function queryFailed( query: Query, msg: string, link?: string, errors?: SupersetError[], ): SqlLabThunkAction { return function (dispatch) { dispatch(logFailedQuery(query, errors)); dispatch(createQueryFailedAction(query, msg, link, errors)); }; } export function stopQuery(query: Query) { return { type: STOP_QUERY, query } as const; } export function clearQueryResults(query: Query): SqlLabAction { return { type: CLEAR_QUERY_RESULTS, query }; } export function removeDataPreview(table: Table): SqlLabAction { return { type: REMOVE_DATA_PREVIEW, table }; } export function requestQueryResults(query: Query): SqlLabAction { return { type: REQUEST_QUERY_RESULTS, query }; } export function fetchQueryResults( query: Query, displayLimit?: number, timeoutInMs?: number, ): SqlLabThunkAction> { return function (dispatch: AppDispatch, getState: GetState) { const { SQLLAB_QUERY_RESULT_TIMEOUT } = getState().common?.conf ?? {}; dispatch(requestQueryResults(query)); const timeout = timeoutInMs ?? SQLLAB_QUERY_RESULT_TIMEOUT; const controller = new AbortController(); return SupersetClient.post({ endpoint: `/api/v1/sqllab/results/`, jsonPayload: { key: query.resultsKey, rows: displayLimit || null, }, parseMethod: 'json-bigint', ...(timeout && { timeout, signal: controller.signal }), }) .then(({ json }) => { dispatch(querySuccess(query, json as SqlExecuteResponse)); }) .catch(response => { controller.abort(); getClientErrorObject(response).then(error => { const message = error.error || error.statusText || t('Failed at retrieving results'); return dispatch( queryFailed(query, message, error.link, error.errors), ); }); }); }; } export function runQuery( query: Query, runPreviewOnly?: boolean, ): SqlLabThunkAction> { return function (dispatch: AppDispatch) { dispatch(startQuery(query, runPreviewOnly)); const postPayload = { client_id: query.id, database_id: query.dbId, runAsync: query.runAsync, catalog: query.catalog, schema: query.schema, sql: query.sql, sql_editor_id: query.sqlEditorId, tab: query.tab, tmp_table_name: query.tempTable, select_as_cta: query.ctas, ctas_method: query.ctas_method, templateParams: query.templateParams, queryLimit: query.queryLimit, expand_data: true, }; const search = window.location.search || ''; return SupersetClient.post({ endpoint: `/api/v1/sqllab/execute/${search}`, body: JSON.stringify(postPayload), headers: { 'Content-Type': 'application/json' }, parseMethod: 'json-bigint', }) .then(({ json }) => { if (!query.runAsync) { dispatch(querySuccess(query, json as SqlExecuteResponse)); } }) .catch(response => getClientErrorObject(response).then(error => { let message = error.error || error.message || error.statusText || t('Unknown error'); if (message.includes('CSRF token')) { message = t(COMMON_ERR_MESSAGES.SESSION_TIMED_OUT); } dispatch(queryFailed(query, message, error.link, error.errors)); }), ); }; } export function runQueryFromSqlEditor( database: Database | null, queryEditor: QueryEditor, defaultQueryLimit: number, tempTable?: string, ctas?: boolean, ctasMethod?: string, ): SqlLabThunkAction { return function (dispatch: AppDispatch, getState: GetState) { const qe = getUpToDateQuery(getState(), queryEditor, queryEditor.id); const query: Query = { id: nanoid(11), dbId: qe.dbId, sql: qe.selectedText || qe.sql, sqlEditorId: qe.tabViewId ?? qe.id, sqlEditorImmutableId: qe.immutableId, tab: qe.name, catalog: qe.catalog, schema: qe.schema, tempTable, templateParams: qe.templateParams, queryLimit: qe.queryLimit || defaultQueryLimit, runAsync: database ? database.allow_run_async : false, ctas, ctas_method: ctasMethod, updateTabState: !qe.selectedText, }; dispatch(runQuery(query)); }; } export function reRunQuery(query: Query): SqlLabThunkAction { // run Query with a new id return function (dispatch: AppDispatch) { dispatch(runQuery({ ...query, id: nanoid(11) })); }; } export function postStopQuery( query: Query, ): SqlLabThunkAction> { return function (dispatch: AppDispatch) { return SupersetClient.post({ endpoint: '/api/v1/query/stop', body: JSON.stringify({ client_id: query.id }), headers: { 'Content-Type': 'application/json' }, }) .then(() => dispatch(stopQuery(query))) .then(() => dispatch(addSuccessToast(t('Query was stopped.')))) .catch(() => dispatch(addDangerToast(t('Failed to stop query.')))); }; } export function setDatabases(databases: Database[]): SqlLabAction { return { type: SET_DATABASES, databases }; } function migrateTable( table: Table, queryEditorId: string, dispatch: AppDispatch, ): Promise { return SupersetClient.post({ endpoint: encodeURI('/tableschemaview/'), postPayload: { table: { ...table, queryEditorId } }, }) .then(({ json }) => { const newTable = { ...table, id: json.id, queryEditorId, }; return dispatch({ type: MIGRATE_TABLE, oldTable: table, newTable }); }) .catch(() => dispatch( addWarningToast( t( 'Unable to migrate table schema state to backend. Superset will retry ' + 'later. Please contact your administrator if this problem persists.', ), ), ), ); } function migrateQuery( queryId: string, queryEditorId: string, dispatch: AppDispatch, ): Promise { return SupersetClient.post({ endpoint: encodeURI(`/tabstateview/${queryEditorId}/migrate_query`), postPayload: { queryId }, }) .then(() => dispatch({ type: MIGRATE_QUERY, queryId, queryEditorId })) .catch(() => dispatch( addWarningToast( t( 'Unable to migrate query state to backend. Superset will retry later. ' + 'Please contact your administrator if this problem persists.', ), ), ), ); } /** * Persist QueryEditor from local storage to backend tab state. * This ensures that when new tabs are created, query editors are * asynchronously stored in local storage and periodically synchronized * with the backend. * When switching to persistence mode, the QueryEditors previously * stored in local storage will also be synchronized to the backend * through syncQueryEditor. */ export function syncQueryEditor( queryEditor: QueryEditor, ): SqlLabThunkAction> { return function (dispatch: AppDispatch, getState: GetState) { const { tables, queries } = getState().sqlLab; const localStorageTables = tables.filter( (table: Table) => table.inLocalStorage && table.queryEditorId === queryEditor.id, ); const localStorageQueries = Object.values(queries).filter( query => query.inLocalStorage && query.sqlEditorId === queryEditor.id, ); return SupersetClient.post({ endpoint: '/tabstateview/', postPayload: { queryEditor }, }) .then(({ json }) => { const newQueryEditor = { ...queryEditor, inLocalStorage: false, loaded: true, tabViewId: json.id.toString(), }; dispatch({ type: MIGRATE_QUERY_EDITOR, oldQueryEditor: queryEditor, newQueryEditor, }); return Promise.all([ ...localStorageTables.map((table: Table) => migrateTable(table, newQueryEditor.tabViewId!, dispatch), ), ...localStorageQueries.map((query: Query) => migrateQuery(query.id, newQueryEditor.tabViewId!, dispatch), ), ]); }) .catch(() => dispatch( addWarningToast( t( 'Unable to migrate query editor state to backend. Superset will retry ' + 'later. Please contact your administrator if this problem persists.', ), ), ), ); }; } export function addQueryEditor( queryEditor: Partial, ): SqlLabAction { const newQueryEditor = { ...queryEditor, id: nanoid(11), immutableId: nanoid(11), loaded: true, inLocalStorage: true, }; return { type: ADD_QUERY_EDITOR, queryEditor: newQueryEditor, }; } export function addNewQueryEditor(): SqlLabThunkAction { return function (dispatch: AppDispatch, getState: GetState) { const { sqlLab: { queryEditors, tabHistory, unsavedQueryEditor, databases }, common, } = getState(); const defaultDbId = common.conf.SQLLAB_DEFAULT_DBID as number | undefined; const activeQueryEditor = queryEditors.find( (qe: QueryEditor) => qe.id === tabHistory[tabHistory.length - 1], ) ?? { id: EMPTY_STATE_QE_ID }; const dbIds = Object.values(databases).map( (database: { id: number }) => database.id, ); const firstDbId = dbIds.length > 0 ? Math.min(...dbIds) : undefined; const { dbId, catalog, schema, queryLimit, autorun } = { ...queryEditors[0], ...activeQueryEditor, ...(unsavedQueryEditor.id === activeQueryEditor?.id && unsavedQueryEditor), } as Partial; const warning = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) ? '' : t( '-- Note: Unless you save your query, these tabs will NOT persist if you clear your cookies or change browsers.\n\n', ); const name = newQueryTabName( queryEditors?.map((qe: QueryEditor) => ({ ...qe, ...(qe.id === unsavedQueryEditor.id && unsavedQueryEditor), })) || [], ); return dispatch( addQueryEditor({ dbId: dbId || defaultDbId || firstDbId, catalog, schema, autorun: autorun ?? false, sql: `${warning}SELECT ...`, queryLimit: queryLimit || common.conf.DEFAULT_SQLLAB_LIMIT, name, }), ); }; } export function cloneQueryToNewTab( query: Query, autorun: boolean, ): SqlLabThunkAction { return function (dispatch: AppDispatch, getState: GetState) { const state = getState(); const { queryEditors, unsavedQueryEditor, tabHistory } = state.sqlLab; const sourceQueryEditor = { ...queryEditors.find( (qe: QueryEditor) => qe.id === tabHistory[tabHistory.length - 1], ), ...(tabHistory[tabHistory.length - 1] === unsavedQueryEditor.id && unsavedQueryEditor), }; const queryEditor = { name: t('Copy of %s', sourceQueryEditor.name), dbId: query.dbId, catalog: query.catalog, schema: query.schema ?? undefined, autorun, sql: query.sql, queryLimit: sourceQueryEditor.queryLimit, templateParams: sourceQueryEditor.templateParams, }; return dispatch(addQueryEditor(queryEditor)); }; } export function setLastUpdatedActiveTab(queryEditorId: string): SqlLabAction { return { type: SET_LAST_UPDATED_ACTIVE_TAB, queryEditorId, }; } export function setActiveQueryEditor(queryEditor: QueryEditor): SqlLabAction { return { type: SET_ACTIVE_QUERY_EDITOR, queryEditor, }; } export function switchQueryEditor(goBackward = false): SqlLabThunkAction { return function (dispatch: AppDispatch, getState: GetState) { const { sqlLab } = getState(); const { queryEditors, tabHistory } = sqlLab; const qeid = tabHistory[tabHistory.length - 1]; const currentIndex = queryEditors.findIndex( (qe: QueryEditor) => qe.id === qeid, ); const nextIndex = goBackward ? currentIndex - 1 + queryEditors.length : currentIndex + 1; const newQueryEditor = queryEditors[nextIndex % queryEditors.length]; dispatch(setActiveQueryEditor(newQueryEditor)); }; } export function loadQueryEditor(queryEditor: QueryEditor): SqlLabAction { return { type: LOAD_QUERY_EDITOR, queryEditor }; } export interface TableSchema { description: { columns: unknown[]; selectStar: string; primaryKey: unknown; foreignKeys: unknown[]; indexes: unknown[]; dataPreviewQueryId: string; } | null; database_id: number; tab_state_id: number; catalog: string; schema: string; table: string; expanded: boolean; id: string; } export function setTables(tableSchemas: TableSchema[]): SqlLabAction { const tables = tableSchemas .filter((tableSchema: TableSchema) => tableSchema.description !== null) .map((tableSchema: TableSchema) => { const { columns, selectStar, primaryKey, foreignKeys, indexes, dataPreviewQueryId, } = tableSchema.description!; return { dbId: tableSchema.database_id, queryEditorId: tableSchema.tab_state_id.toString(), catalog: tableSchema.catalog, schema: tableSchema.schema, name: tableSchema.table, expanded: tableSchema.expanded, id: tableSchema.id, dataPreviewQueryId, columns, selectStar, primaryKey, foreignKeys, indexes, isMetadataLoading: false, isExtraMetadataLoading: false, }; }); return { type: SET_TABLES, tables }; } export function fetchQueryEditor( queryEditor: QueryEditor, displayLimit: number, ): SqlLabThunkAction { return function (dispatch: AppDispatch) { const queryEditorId = queryEditor.tabViewId ?? queryEditor.id; SupersetClient.get({ endpoint: encodeURI(`/tabstateview/${queryEditorId}`), }) .then(({ json }) => { const loadedQueryEditor = { id: json.id.toString(), loaded: true, name: json.label, sql: json.sql, selectedText: null, latestQueryId: json.latest_query?.id, autorun: json.autorun, dbId: json.database_id, templateParams: json.template_params, catalog: json.catalog, schema: json.schema, queryLimit: json.query_limit, remoteId: json.saved_query?.id, hideLeftBar: json.hide_left_bar, }; dispatch(loadQueryEditor(loadedQueryEditor as unknown as QueryEditor)); dispatch(setTables(json.table_schemas || [])); if (json.latest_query?.resultsKey) { dispatch(fetchQueryResults(json.latest_query, displayLimit)); } }) .catch(response => { if (response.status !== 404) { return dispatch( addDangerToast(t('An error occurred while fetching tab state')), ); } return dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor }); }); }; } export function setActiveSouthPaneTab(tabId: string): SqlLabAction { return { type: SET_ACTIVE_SOUTHPANE_TAB, tabId }; } export function toggleLeftBar(queryEditor: QueryEditor): SqlLabAction { const hideLeftBar = !queryEditor.hideLeftBar; return { type: QUERY_EDITOR_TOGGLE_LEFT_BAR, queryEditor, hideLeftBar, }; } export function clearDestoryedQueryEditor(queryEditorId: string): SqlLabAction { return { type: CLEAR_DESTROYED_QUERY_EDITOR, queryEditorId }; } export function removeQueryEditor(queryEditor: QueryEditor): SqlLabAction { return { type: REMOVE_QUERY_EDITOR, queryEditor }; } export function removeAllOtherQueryEditors( queryEditor: QueryEditor, ): SqlLabThunkAction { return function (dispatch: AppDispatch, getState: GetState) { const { sqlLab } = getState(); sqlLab.queryEditors?.forEach((otherQueryEditor: QueryEditor) => { if (otherQueryEditor.id !== queryEditor.id) { dispatch(removeQueryEditor(otherQueryEditor)); } }); }; } export function removeQuery(query: Query): SqlLabThunkAction> { return function (dispatch: AppDispatch) { const queryEditorId = query.sqlEditorId ?? query.id; const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) ? SupersetClient.delete({ endpoint: encodeURI( `/tabstateview/${queryEditorId}/query/${query.id}`, ), }) : Promise.resolve(); return sync .then(() => dispatch({ type: REMOVE_QUERY, query })) .catch(() => dispatch( addDangerToast( t( 'An error occurred while removing query. Please contact your administrator.', ), ), ), ); }; } export function queryEditorSetDb( queryEditor: Partial, dbId: number, ): SqlLabAction { return { type: QUERY_EDITOR_SETDB, queryEditor, dbId }; } export function queryEditorSetCatalog( queryEditor: Partial | null, catalog: string | null, ): SqlLabAction { return { type: QUERY_EDITOR_SET_CATALOG, queryEditor: queryEditor || {}, catalog, }; } export function queryEditorSetSchema( queryEditor: Partial | null, schema: string | null, ): SqlLabAction { return { type: QUERY_EDITOR_SET_SCHEMA, queryEditor: queryEditor || {}, schema, }; } export function queryEditorSetAutorun( queryEditor: Partial, autorun: boolean, ): SqlLabAction { return { type: QUERY_EDITOR_SET_AUTORUN, queryEditor, autorun }; } export function queryEditorSetTitle( queryEditor: Partial, name: string, id: string, ): SqlLabAction { return { type: QUERY_EDITOR_SET_TITLE, queryEditor: { ...queryEditor, id }, name, }; } export function saveQuery( query: Partial, clientId: string, ): SqlLabThunkAction | void>> { const { id: _id, ...payload } = convertQueryToServer( query as Record, ); return (dispatch: AppDispatch) => SupersetClient.post({ endpoint: '/api/v1/saved_query/', jsonPayload: convertQueryToServer(payload as Record), }) .then(result => { const savedQuery = convertQueryToClient({ id: result.json.id, ...result.json.result, }); dispatch({ type: QUERY_EDITOR_SAVED, query, clientId, result: savedQuery, }); dispatch(queryEditorSetTitle(query, query.name ?? '', clientId)); return savedQuery; }) .catch(() => dispatch(addDangerToast(t('Your query could not be saved'))), ); } export const addSavedQueryToTabState = ( queryEditor: QueryEditor, savedQuery: { remoteId: string }, ): SqlLabThunkAction> => (dispatch: AppDispatch) => { const queryEditorId = queryEditor.tabViewId ?? queryEditor.id; const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) ? SupersetClient.put({ endpoint: `/tabstateview/${queryEditorId}`, postPayload: { saved_query_id: savedQuery.remoteId }, }) : Promise.resolve(); return sync .catch(() => { dispatch(addDangerToast(t('Your query was not properly saved'))); }) .then(() => { dispatch(addSuccessToast(t('Your query was saved'))); }); }; export function updateSavedQuery( query: Partial, clientId: string, ): SqlLabThunkAction> { const { id: _id, ...payload } = convertQueryToServer( query as Record, ); return (dispatch: AppDispatch) => SupersetClient.put({ endpoint: `/api/v1/saved_query/${query.remoteId}`, jsonPayload: convertQueryToServer(payload as Record), }) .then(() => { dispatch(addSuccessToast(t('Your query was updated'))); dispatch(queryEditorSetTitle(query, query.name ?? '', clientId)); }) .catch(e => { const message = t('Your query could not be updated'); // eslint-disable-next-line no-console console.error(message, e); dispatch(addDangerToast(message)); }) .then(() => { dispatch(updateQueryEditor(query)); }); } export function queryEditorSetSql( queryEditor: Partial, sql: string, queryId?: string, ): SqlLabAction { return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql, queryId }; } export function queryEditorSetCursorPosition( queryEditor: Partial, position: { row: number; column: number }, ): SqlLabAction { return { type: QUERY_EDITOR_SET_CURSOR_POSITION, queryEditor, position }; } export function queryEditorSetAndSaveSql( targetQueryEditor: Partial, sql: string, queryId?: string, ): SqlLabThunkAction> { return function (dispatch: AppDispatch, getState: GetState) { const queryEditor = getUpToDateQuery(getState(), targetQueryEditor); // saved query and set tab state use this action dispatch(queryEditorSetSql(queryEditor, sql, queryId)); const queryEditorId = queryEditor.tabViewId ?? queryEditor.id; if (isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)) { return SupersetClient.put({ endpoint: encodeURI(`/tabstateview/${queryEditorId}`), postPayload: { sql, latest_query_id: queryId }, }).catch(() => dispatch( addDangerToast( t( 'An error occurred while storing your query in the backend. To ' + 'avoid losing your changes, please save your query using the ' + '"Save Query" button.', ), ), ), ); } return Promise.resolve(); }; } export function formatQuery( queryEditor: QueryEditor, ): SqlLabThunkAction> { return function (dispatch: AppDispatch, getState: GetState) { const { sql, dbId, templateParams } = getUpToDateQuery( getState(), queryEditor, ); const body: { sql: string; database_id?: number; template_params?: string; } = { sql }; // Include database_id and template_params if available for Jinja processing if (dbId) { body.database_id = dbId; } if (templateParams) { // Send templateParams as a JSON string to match the backend schema body.template_params = typeof templateParams === 'string' ? templateParams : JSON.stringify(templateParams); } return SupersetClient.post({ endpoint: `/api/v1/sqllab/format_sql/`, body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, }).then(({ json }) => { dispatch(queryEditorSetSql(queryEditor, json.result)); }); }; } export function queryEditorSetQueryLimit( queryEditor: Partial, queryLimit: number, ): SqlLabAction { return { type: QUERY_EDITOR_SET_QUERY_LIMIT, queryEditor, queryLimit, }; } export function queryEditorSetTemplateParams( queryEditor: Partial, templateParams: string, ): SqlLabAction { return { type: QUERY_EDITOR_SET_TEMPLATE_PARAMS, queryEditor, templateParams, }; } export function queryEditorSetSelectedText( queryEditor: Partial, sql: string | null, ): SqlLabAction { return { type: QUERY_EDITOR_SET_SELECTED_TEXT, queryEditor, sql }; } export function mergeTable( table: Partial
, query?: Query, prepend?: boolean, ): SqlLabAction { return { type: MERGE_TABLE, table, query, prepend }; } export function addTable( queryEditor: Partial, tableName: string, catalogName: string | null, schemaName: string, expanded = true, ): SqlLabThunkAction { return function (dispatch: AppDispatch, getState: GetState) { const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id); const table = { dbId, queryEditorId: queryEditor.tabViewId ?? queryEditor.id, catalog: catalogName, schema: schemaName, name: tableName, }; dispatch( mergeTable({ ...table, id: nanoid(11), expanded, }), ); }; } export interface NewTable { id?: string; dbId: number | string; catalog?: string | null; schema?: string; name: string; queryEditorId?: string; selectStar?: string; previewQueryId?: string; } export function runTablePreviewQuery( newTable: NewTable, runPreviewOnly?: boolean, ): SqlLabThunkAction> { return function (dispatch: AppDispatch, getState: GetState) { const { sqlLab: { databases }, } = getState(); const database = databases[newTable.dbId]; const { dbId, catalog, schema } = newTable; if (database && !database.disable_data_preview) { const dataPreviewQuery: Query = { id: newTable.previewQueryId ?? nanoid(11), dbId: dbId as number | undefined, catalog: catalog ?? undefined, schema: schema ?? '', sql: newTable.selectStar ?? '', tableName: newTable.name, sqlEditorId: null, tab: '', runAsync: database.allow_run_async, ctas: false, isDataPreview: true, queryLimit: PREVIEW_QUERY_LIMIT, }; if (runPreviewOnly) { return dispatch(runQuery(dataPreviewQuery, runPreviewOnly)); } return Promise.all([ dispatch( mergeTable( { id: newTable.id, dbId: newTable.dbId as number | undefined, catalog: newTable.catalog ?? undefined, schema: newTable.schema ?? '', name: newTable.name, queryEditorId: newTable.queryEditorId, dataPreviewQueryId: dataPreviewQuery.id, }, dataPreviewQuery, ), ), dispatch(runQuery(dataPreviewQuery)), ]); } return Promise.resolve(); }; } export interface TableMetaData { columns?: unknown[]; selectStar?: string; primaryKey?: unknown; foreignKeys?: unknown[]; indexes?: unknown[]; } export function syncTable( table: Table, tableMetadata: TableMetaData, finalQueryEditorId?: string, ): SqlLabThunkAction> { return function (dispatch: AppDispatch) { const finalTable = { ...table, queryEditorId: finalQueryEditorId }; const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) ? SupersetClient.post({ endpoint: encodeURI('/tableschemaview/'), postPayload: { table: { ...tableMetadata, ...finalTable } }, }) : Promise.resolve({ json: { id: table.id } }); return sync .then(({ json: resultJson }) => { const newTable = { ...table, id: `${resultJson.id}` }; dispatch( mergeTable({ ...newTable, expanded: true, initialized: true, }), ); }) .catch(() => dispatch( addDangerToast( t( 'An error occurred while fetching table metadata. ' + 'Please contact your administrator.', ), ), ), ); }; } export function changeDataPreviewId( oldQueryId: string, newQuery: Query, ): SqlLabAction { return { type: CHANGE_DATA_PREVIEW_ID, oldQueryId, newQuery }; } export function reFetchQueryResults(query: Query): SqlLabThunkAction { return function (dispatch: AppDispatch) { const newQuery: Query = { id: nanoid(), dbId: query.dbId, sql: query.sql, tableName: query.tableName, sqlEditorId: null, tab: '', runAsync: false, ctas: false, queryLimit: query.queryLimit, isDataPreview: query.isDataPreview, }; dispatch(runQuery(newQuery)); dispatch(changeDataPreviewId(query.id, newQuery)); }; } export function expandTable(table: Table): SqlLabThunkAction> { return function (dispatch: AppDispatch) { const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) && table.initialized ? SupersetClient.post({ endpoint: encodeURI(`/tableschemaview/${table.id}/expanded`), postPayload: { expanded: true }, }) : Promise.resolve(); return sync .then(() => dispatch({ type: EXPAND_TABLE, table })) .catch(() => dispatch( addDangerToast( t( 'An error occurred while expanding the table schema. ' + 'Please contact your administrator.', ), ), ), ); }; } export function collapseTable( table: Table, ): SqlLabThunkAction> { return function (dispatch: AppDispatch) { const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) && table.initialized ? SupersetClient.post({ endpoint: encodeURI(`/tableschemaview/${table.id}/expanded`), postPayload: { expanded: false }, }) : Promise.resolve(); return sync .then(() => dispatch({ type: COLLAPSE_TABLE, table })) .catch(() => dispatch( addDangerToast( t( 'An error occurred while collapsing the table schema. ' + 'Please contact your administrator.', ), ), ), ); }; } export function removeTables( tables: Table[], ): SqlLabThunkAction> { return function (dispatch: AppDispatch) { const tablesToRemove = tables?.filter(Boolean) ?? []; const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) ? Promise.all( tablesToRemove.map((table: Table) => table.initialized ? SupersetClient.delete({ endpoint: encodeURI(`/tableschemaview/${table.id}`), }) : Promise.resolve(), ), ) : Promise.resolve(); return sync .then(() => dispatch({ type: REMOVE_TABLES, tables: tablesToRemove })) .catch(() => dispatch( addDangerToast( t( 'An error occurred while removing the table schema. ' + 'Please contact your administrator.', ), ), ), ); }; } export function refreshQueries( alteredQueries: Record, ): SqlLabAction { return { type: REFRESH_QUERIES, alteredQueries }; } export function setUserOffline(offline: boolean): SqlLabAction { return { type: SET_USER_OFFLINE, offline }; } export function persistEditorHeight( queryEditor: QueryEditor, northPercent: number, southPercent: number, ): SqlLabAction { return { type: QUERY_EDITOR_PERSIST_HEIGHT, queryEditor, northPercent, southPercent, }; } export function popPermalink(key: string): SqlLabThunkAction> { return function (dispatch: AppDispatch) { return SupersetClient.get({ endpoint: `/api/v1/sqllab/permalink/${key}` }) .then(({ json }) => dispatch( addQueryEditor({ name: json.name ? json.name : t('Shared query'), dbId: json.dbId ? parseInt(json.dbId, 10) : undefined, catalog: json.catalog ?? null, schema: json.schema ?? undefined, autorun: json.autorun ? json.autorun : false, sql: json.sql ? json.sql : 'SELECT ...', templateParams: json.templateParams, }), ), ) .catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY))); }; } export function popStoredQuery( urlId: string, ): SqlLabThunkAction> { return function (dispatch: AppDispatch) { return SupersetClient.get({ endpoint: `/api/v1/sqllab/permalink/kv:${urlId}`, }) .then(({ json }) => dispatch( addQueryEditor({ name: json.name ? json.name : t('Shared query'), dbId: json.dbId ? parseInt(json.dbId, 10) : undefined, catalog: json.catalog ?? null, schema: json.schema ?? undefined, autorun: json.autorun ? json.autorun : false, sql: json.sql ? json.sql : 'SELECT ...', templateParams: json.templateParams, }), ), ) .catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY))); }; } export function popSavedQuery( saveQueryId: string, ): SqlLabThunkAction> { return function (dispatch: AppDispatch) { return SupersetClient.get({ endpoint: `/api/v1/saved_query/${saveQueryId}`, }) .then(({ json }) => { const queryEditorProps = { ...convertQueryToClient(json.result), loaded: true, autorun: false, } as Record; const tmpAdaptedProps = { name: queryEditorProps.name as string, dbId: (queryEditorProps.database as { id: number }).id, catalog: queryEditorProps.catalog as string, schema: queryEditorProps.schema as string, sql: queryEditorProps.sql as string, templateParams: queryEditorProps.templateParams as string, remoteId: queryEditorProps.remoteId as number | null, }; return dispatch(addQueryEditor(tmpAdaptedProps)); }) .catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY))); }; } export function popQuery(queryId: string): SqlLabThunkAction> { return function (dispatch: AppDispatch) { return SupersetClient.get({ endpoint: `/api/v1/query/${queryId}`, }) .then(({ json }) => { const queryData = json.result; const queryEditorProps = { dbId: queryData.database.id, catalog: queryData.catalog, schema: queryData.schema, sql: queryData.sql, name: t('Copy of %s', queryData.tab_name), autorun: false, }; return dispatch(addQueryEditor(queryEditorProps)); }) .catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY))); }; } export function popDatasourceQuery( datasourceKey: string, sql?: string, ): SqlLabThunkAction> { return function (dispatch: AppDispatch) { const QUERY_TEXT = t('Query'); const datasetId = datasourceKey.split('__')[0]; const queryParams = rison.encode({ keys: ['none'], columns: ['name', 'schema', 'database.id', 'select_star'], }); return SupersetClient.get({ endpoint: `/api/v1/dataset/${datasetId}?q=${queryParams}`, }) .then(({ json }) => dispatch( addQueryEditor({ name: `${QUERY_TEXT} ${json.result.name}`, dbId: json.result.database.id, schema: json.result.schema, autorun: sql !== undefined, sql: sql || json.result.select_star, }), ), ) .catch(() => dispatch(addDangerToast(t("The datasource couldn't be loaded"))), ); }; } export function createDatasourceStarted(): SqlLabAction { return { type: CREATE_DATASOURCE_STARTED }; } export function createDatasourceSuccess(data: { id: number }): SqlLabAction { const datasource = `${data.id}__table`; return { type: CREATE_DATASOURCE_SUCCESS, datasource }; } export function createDatasourceFailed(err: string): SqlLabAction { return { type: CREATE_DATASOURCE_FAILED, err }; } export interface VizOptions { dbId: number; catalog?: string | null; schema: string; datasourceName: string; sql: string; templateParams?: string; } export function createDatasource( vizOptions: VizOptions, ): SqlLabThunkAction> { return (dispatch: AppDispatch) => { dispatch(createDatasourceStarted()); const { dbId, catalog, schema, datasourceName, sql, templateParams } = vizOptions; return SupersetClient.post({ endpoint: '/api/v1/dataset/', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ database: dbId, catalog, schema, sql, table_name: datasourceName, is_managed_externally: false, external_url: null, template_params: templateParams, }), }) .then(({ json }) => { const result = json as { id: number }; dispatch(createDatasourceSuccess(result)); return result; }) .catch(error => { getClientErrorObject(error).then(e => { dispatch(addDangerToast(e.error)); }); dispatch( createDatasourceFailed( t('An error occurred while creating the data source'), ), ); return Promise.reject(); }); }; } export function createCtasDatasource( vizOptions: Record, ): SqlLabThunkAction> { return (dispatch: AppDispatch) => { dispatch(createDatasourceStarted()); return SupersetClient.post({ endpoint: '/api/v1/dataset/get_or_create/', jsonPayload: vizOptions, }) .then(({ json }) => { const result = json.result as { table_id: number }; // The endpoint's `result.table_id` IS the dataset id; normalize so // createDatasourceSuccess's `${data.id}__table` resolves correctly. // Without this, the CTAS Explore button silently produced // `"undefined__table"` because `result.id` doesn't exist. dispatch(createDatasourceSuccess({ id: result.table_id })); return result; }) .catch(() => { const errorMsg = t('An error occurred while creating the data source'); dispatch(createDatasourceFailed(errorMsg)); return Promise.reject(new Error(errorMsg)); }); }; }