/** * 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 { QueryState } from '@superset-ui/core'; import sqlLabReducer from 'src/SqlLab/reducers/sqlLab'; import * as actions from 'src/SqlLab/actions/sqlLab'; import type { SqlLabAction } from 'src/SqlLab/actions/sqlLab'; import type { SqlLabRootState } from 'src/SqlLab/types'; import { table, databases, initialState as mockState } from '../fixtures'; type SqlLabState = SqlLabRootState['sqlLab']; const initialState = mockState.sqlLab as unknown as SqlLabState; // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('sqlLabReducer', () => { test('should merge databases instead of replacing existing database state', () => { const existingDb = { ...databases.result[0], extra: '{}', }; const existingDbId = Number(existingDb.id); const incomingDb = { ...databases.result[0], id: existingDbId + 1, database_name: 'new database', extra: '{}', }; const incomingDbId = Number(incomingDb.id); const state = { ...initialState, databases: { [existingDbId]: existingDb, }, } as any; const action = actions.setDatabases([incomingDb] as any); const newState = sqlLabReducer(state, action); expect(newState.databases[existingDbId]).toEqual(existingDb); expect(newState.databases[incomingDbId]).toEqual({ ...incomingDb, extra_json: {}, }); }); test('should default extra_json to an empty object when extra is unset', () => { // `extra` is nullable in the metadata database, and JSON.parse(extra || '') // is guaranteed to throw because '' is never valid JSON, so one such row // took down the whole reducer. const incomingDb = { ...databases.result[0], extra: null, }; const incomingDbId = Number(incomingDb.id); const action = actions.setDatabases([incomingDb] as any); const newState = sqlLabReducer(initialState, action); expect(newState.databases[incomingDbId]).toEqual({ ...incomingDb, extra_json: {}, }); }); test('defaults extra_json when a database has malformed extra', () => { const incomingDb = { ...databases.result[0], extra: '{not json' }; const newState = sqlLabReducer( initialState, actions.setDatabases([incomingDb] as any), ); expect(newState.databases[Number(incomingDb.id)].extra_json).toEqual({}); }); test('keeps a valid extra payload', () => { const incomingDb = { ...databases.result[0], extra: '{"engine_params": {"pool_size": 5}}', }; const newState = sqlLabReducer( initialState, actions.setDatabases([incomingDb] as any), ); expect(newState.databases[Number(incomingDb.id)].extra_json).toEqual({ engine_params: { pool_size: 5 }, }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('Query editors actions', () => { let newState: SqlLabState; let defaultQueryEditor: SqlLabState['queryEditors'][number]; let qe: SqlLabState['queryEditors'][number] | undefined; beforeEach(() => { newState = { ...initialState }; defaultQueryEditor = newState.queryEditors[0]; const action = { type: actions.ADD_QUERY_EDITOR, queryEditor: { ...initialState.queryEditors[0], id: 'abcd' }, }; newState = sqlLabReducer(newState, action); qe = newState.queryEditors.find(e => e.id === 'abcd'); }); test('should add a query editor', () => { expect(newState.queryEditors).toHaveLength( initialState.queryEditors.length + 1, ); }); test('should merge the current unsaved changes when adding a query editor', () => { const expectedTitle = 'new updated title'; const updateAction = { type: actions.QUERY_EDITOR_SET_TITLE, queryEditor: initialState.queryEditors[0], name: expectedTitle, }; newState = sqlLabReducer(newState, updateAction); const addAction = { type: actions.ADD_QUERY_EDITOR, queryEditor: { ...initialState.queryEditors[0], id: 'efgh' }, }; newState = sqlLabReducer(newState, addAction); expect(newState.queryEditors[0].name).toEqual(expectedTitle); expect( newState.queryEditors[newState.queryEditors.length - 1].id, ).toEqual('efgh'); }); test('should remove a query editor', () => { expect(newState.queryEditors).toHaveLength( initialState.queryEditors.length + 1, ); const action = { type: actions.REMOVE_QUERY_EDITOR, queryEditor: qe, }; newState = sqlLabReducer(newState, action); expect(newState.queryEditors).toHaveLength( initialState.queryEditors.length, ); }); test('should select the latest query editor when tabHistory is empty', () => { const currentQE = newState.queryEditors[0]; newState = { ...initialState, tabHistory: [initialState.queryEditors[0].id], }; const action = { type: actions.REMOVE_QUERY_EDITOR, queryEditor: currentQE, }; newState = sqlLabReducer(newState, action); expect(newState.queryEditors).toHaveLength( initialState.queryEditors.length - 1, ); expect(newState.queryEditors.map(qe => qe.id)).not.toContainEqual( currentQE.id, ); expect(newState.tabHistory).toEqual([initialState.queryEditors[2].id]); }); test('should remove a query editor including unsaved changes', () => { expect(newState.queryEditors).toHaveLength( initialState.queryEditors.length + 1, ); const setDbAction = { type: actions.QUERY_EDITOR_SETDB, queryEditor: qe, dbId: 123, }; newState = sqlLabReducer(newState, setDbAction); expect(newState.unsavedQueryEditor.dbId).toEqual(setDbAction.dbId); const removeAction = { type: actions.REMOVE_QUERY_EDITOR, queryEditor: qe, } as SqlLabAction; newState = sqlLabReducer(newState, removeAction); expect(newState.queryEditors).toHaveLength( initialState.queryEditors.length, ); expect(newState.unsavedQueryEditor.dbId).toBeUndefined(); expect(newState.unsavedQueryEditor.id).toBeUndefined(); }); test('should set q query editor active', () => { const expectedTitle = 'new updated title'; const addQueryEditorAction = { type: actions.ADD_QUERY_EDITOR, queryEditor: { ...initialState.queryEditors[0], id: 'abcd' }, }; newState = sqlLabReducer(newState, addQueryEditorAction); const updateAction = { type: actions.QUERY_EDITOR_SET_TITLE, queryEditor: initialState.queryEditors[1], name: expectedTitle, }; newState = sqlLabReducer(newState, updateAction); const setActiveQueryEditorAction = { type: actions.SET_ACTIVE_QUERY_EDITOR, queryEditor: defaultQueryEditor, }; newState = sqlLabReducer(newState, setActiveQueryEditorAction); expect(newState.tabHistory[newState.tabHistory.length - 1]).toBe( defaultQueryEditor.id, ); expect(newState.queryEditors[1].name).toEqual(expectedTitle); }); test('should not fail while setting DB', () => { const dbId = 9; const action = { type: actions.QUERY_EDITOR_SETDB, queryEditor: qe, dbId, }; newState = sqlLabReducer(newState, action); expect(newState.unsavedQueryEditor.dbId).toBe(dbId); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should not fail while setting schema', () => { const schema = 'foo'; const action = { type: actions.QUERY_EDITOR_SET_SCHEMA, queryEditor: qe, schema, }; newState = sqlLabReducer(newState, action); expect(newState.unsavedQueryEditor.schema).toBe(schema); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should not fail while setting autorun', () => { const action = { type: actions.QUERY_EDITOR_SET_AUTORUN, queryEditor: qe, }; newState = sqlLabReducer(newState, { ...action, autorun: false }); expect(newState.unsavedQueryEditor.autorun).toBe(false); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); newState = sqlLabReducer(newState, { ...action, autorun: true }); expect(newState.unsavedQueryEditor.autorun).toBe(true); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should not fail while setting title', () => { const title = 'Untitled Query 1'; const action = { type: actions.QUERY_EDITOR_SET_TITLE, queryEditor: qe, name: title, }; newState = sqlLabReducer(newState, action); expect(newState.unsavedQueryEditor.name).toBe(title); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should not fail while setting Sql', () => { const sql = 'SELECT nothing from dev_null'; const action = { type: actions.QUERY_EDITOR_SET_SQL, queryEditor: qe, sql, }; newState = sqlLabReducer(newState, action); expect(newState.unsavedQueryEditor.sql).toBe(sql); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should set Sql when dispatched with tabViewId (backend persistence)', () => { // Simulate SqllabBackendPersistence: queryEditor gets a tabViewId after save const tabViewId = 'tab-view-42'; const migrateAction = { type: actions.MIGRATE_QUERY_EDITOR, oldQueryEditor: qe, newQueryEditor: { ...qe!, tabViewId, inLocalStorage: false }, }; newState = sqlLabReducer(newState, migrateAction as SqlLabAction); // Restore SQL using tabViewId (as restoreSql in QueryTable does) const sql = 'SELECT restored_query FROM history'; const restoreAction = { type: actions.QUERY_EDITOR_SET_SQL, queryEditor: { id: tabViewId }, sql, }; newState = sqlLabReducer(newState, restoreAction); expect(newState.unsavedQueryEditor.sql).toBe(sql); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should not fail while setting queryLimit', () => { const queryLimit = 101; const action = { type: actions.QUERY_EDITOR_SET_QUERY_LIMIT, queryEditor: qe, queryLimit, }; newState = sqlLabReducer(newState, action); expect(newState.unsavedQueryEditor.queryLimit).toBe(queryLimit); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should set selectedText', () => { const selectedText = 'TEST'; const action = { type: actions.QUERY_EDITOR_SET_SELECTED_TEXT, queryEditor: qe, sql: selectedText, }; expect(qe!.selectedText).toBeFalsy(); newState = sqlLabReducer(newState, action); expect(newState.unsavedQueryEditor.selectedText).toBe(selectedText); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should not wiped out unsaved changes while delayed async call intercepted', () => { const expectedSql = 'Updated SQL WORKING IN PROGRESS--'; const action = { type: actions.QUERY_EDITOR_SET_SQL, queryEditor: qe, sql: expectedSql, }; newState = sqlLabReducer(newState, action); expect(newState.unsavedQueryEditor.sql).toBe(expectedSql); const interceptedAction = { type: actions.QUERY_EDITOR_PERSIST_HEIGHT, queryEditor: newState.queryEditors[0], northPercent: 46, southPercent: 54, }; newState = sqlLabReducer(newState, interceptedAction); expect(newState.unsavedQueryEditor.sql).toBe(expectedSql); expect(newState.queryEditors[0].northPercent).toBe( interceptedAction.northPercent, ); }); test('should migrate query editor by new query editor id', () => { const { length } = newState.queryEditors; const index = newState.queryEditors.findIndex(({ id }) => id === qe!.id); const newQueryEditor = { ...qe!, tabViewId: 'updatedNewId', schema: 'updatedSchema', inLocalStorage: false, }; const action = { type: actions.MIGRATE_QUERY_EDITOR, oldQueryEditor: qe, newQueryEditor, }; newState = sqlLabReducer(newState, action as SqlLabAction); expect(newState.queryEditors[index].id).toEqual(qe!.id); expect(newState.queryEditors[index].tabViewId).toEqual('updatedNewId'); expect(newState.queryEditors[index]).toEqual(newQueryEditor); const removeAction = { type: actions.REMOVE_QUERY_EDITOR, queryEditor: newQueryEditor, }; newState = sqlLabReducer(newState, removeAction); expect(newState.queryEditors).toHaveLength(length - 1); expect(Object.keys(newState.destroyedQueryEditors)).toContain( newQueryEditor.tabViewId, ); }); test('should toggle hideLeftBar via queryEditorId for the active editor', () => { const action = { type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR, queryEditorId: qe!.id, hideLeftBar: true, }; newState = sqlLabReducer(newState, action as SqlLabAction); expect(newState.unsavedQueryEditor.hideLeftBar).toBe(true); expect(newState.unsavedQueryEditor.id).toBe(qe!.id); }); test('should toggle hideLeftBar back to false via queryEditorId', () => { // first set to true newState = sqlLabReducer(newState, { type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR, queryEditorId: qe!.id, hideLeftBar: true, } as SqlLabAction); // then back to false newState = sqlLabReducer(newState, { type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR, queryEditorId: qe!.id, hideLeftBar: false, } as SqlLabAction); expect(newState.unsavedQueryEditor.hideLeftBar).toBe(false); }); test('should toggle hideLeftBar in queryEditors array for non-active editor', () => { const nonActiveId = defaultQueryEditor.id; const action = { type: actions.QUERY_EDITOR_TOGGLE_LEFT_BAR, queryEditorId: nonActiveId, hideLeftBar: true, }; newState = sqlLabReducer(newState, action as SqlLabAction); const updated = newState.queryEditors.find(e => e.id === nonActiveId); expect(updated?.hideLeftBar).toBe(true); }); test('should clear the destroyed query editors', () => { const expectedQEId = '1233289'; const action = { type: actions.CLEAR_DESTROYED_QUERY_EDITOR, queryEditorId: expectedQEId, }; newState = sqlLabReducer( { ...newState, destroyedQueryEditors: { [expectedQEId]: Date.now(), }, }, action, ); expect(newState.destroyedQueryEditors).toEqual({}); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('Tables', () => { let newState: SqlLabState; let newTable: any; beforeEach(() => { newTable = { ...table }; const action = { type: actions.MERGE_TABLE, table: newTable, }; newState = sqlLabReducer(initialState, action); newTable = newState.tables[0]; }); test('should add a table', () => { // Testing that beforeEach actually added the table expect(newState.tables).toHaveLength(1); expect(newState.tables[0].expanded).toBe(true); }); test('should merge the table attributes', () => { // Merging the extra attribute newTable.extra = true; const action = { type: actions.MERGE_TABLE, table: newTable, }; newState = sqlLabReducer(newState, action); expect(newState.tables).toHaveLength(1); expect( (newState.tables[0] as unknown as Record).extra, ).toBe(true); }); test('should overwrite table ID be ignored when the existing table is already initialized', () => { const action = { type: actions.MERGE_TABLE, table: newTable, }; newState = sqlLabReducer(newState, action); expect(newState.tables).toHaveLength(1); // Merging the initialized remote id const remoteId = 1; const syncAction = { type: actions.MERGE_TABLE, table: { ...newTable, id: remoteId, initialized: true, }, }; newState = sqlLabReducer(newState, syncAction); expect(newState.tables).toHaveLength(1); expect(newState.tables[0].initialized).toBe(true); expect(newState.tables[0].id).toBe(remoteId); const overwriteAction = { type: actions.MERGE_TABLE, table: { id: 'rnd_new_id', ...newTable, }, }; newState = sqlLabReducer(newState, overwriteAction); expect(newState.tables).toHaveLength(1); expect(newState.tables[0].id).toBe(remoteId); }); test('should expand and collapse a table', () => { const collapseTableAction = { type: actions.COLLAPSE_TABLE, table: newTable, }; newState = sqlLabReducer(newState, collapseTableAction); expect(newState.tables[0].expanded).toBe(false); const expandTableAction = { type: actions.EXPAND_TABLE, table: newTable, }; newState = sqlLabReducer(newState, expandTableAction); expect(newState.tables[0].expanded).toBe(true); }); test('should remove a table', () => { const action = { type: actions.REMOVE_TABLES, tables: [newTable], }; newState = sqlLabReducer(newState, action); expect(newState.tables).toHaveLength(0); }); test('should set activeSouthPaneTab when adding expanded table', () => { const expandedTable = { ...table, id: 'expanded_table_id', name: 'expanded_table', expanded: true, }; const action = { type: actions.MERGE_TABLE, table: expandedTable, }; newState = sqlLabReducer(initialState, action); expect(newState.tables).toHaveLength(1); expect(newState.activeSouthPaneTab).toBe(expandedTable.id); }); test('should not set activeSouthPaneTab when adding collapsed table', () => { const collapsedTable = { ...table, id: 'collapsed_table_id', name: 'collapsed_table', expanded: false, }; const action = { type: actions.MERGE_TABLE, table: collapsedTable, }; newState = sqlLabReducer(initialState, action); expect(newState.tables).toHaveLength(1); expect(newState.activeSouthPaneTab).toBe(initialState.activeSouthPaneTab); }); test('should set activeSouthPaneTab when merging existing table with expanded=true', () => { // First add a table with expanded=false const collapsedTable = { ...table, id: 'existing_table_id', name: 'existing_table', expanded: false, }; const addAction = { type: actions.MERGE_TABLE, table: collapsedTable, }; newState = sqlLabReducer(initialState, addAction); const previousActiveSouthPaneTab = newState.activeSouthPaneTab; // Now merge the same table with expanded=true const expandedTable = { ...collapsedTable, expanded: true, }; const mergeAction = { type: actions.MERGE_TABLE, table: expandedTable, }; newState = sqlLabReducer(newState, mergeAction); expect(newState.tables).toHaveLength(1); expect(newState.activeSouthPaneTab).toBe(expandedTable.id); expect(newState.activeSouthPaneTab).not.toBe(previousActiveSouthPaneTab); }); test('should not set activeSouthPaneTab when merging existing table with expanded=false', () => { // First add a table with expanded=true const expandedTable = { ...table, id: 'existing_table_id_2', name: 'existing_table_2', expanded: true, }; const addAction = { type: actions.MERGE_TABLE, table: expandedTable, }; newState = sqlLabReducer(initialState, addAction); expect(newState.activeSouthPaneTab).toBe(expandedTable.id); // Now merge the same table with expanded=false const collapsedTable = { ...expandedTable, expanded: false, }; const mergeAction = { type: actions.MERGE_TABLE, table: collapsedTable, }; newState = sqlLabReducer(newState, mergeAction); expect(newState.tables).toHaveLength(1); expect(newState.activeSouthPaneTab).toBe(expandedTable.id); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('Run Query', () => { const DENORMALIZED_CHANGED_ON = '2023-06-26T07:53:05.439'; const CHANGED_ON_TIMESTAMP = 1687765985439; let newState: any; let query: any; beforeEach(() => { newState = { ...initialState }; query = { id: 'abcd', progress: 0, changed_on: DENORMALIZED_CHANGED_ON, startDttm: CHANGED_ON_TIMESTAMP, state: 'running', cached: false, sqlEditorId: 'dfsadfs', }; }); test('should start a query', () => { const action = { type: actions.START_QUERY, query: { id: 'abcd', progress: 0, changed_on: DENORMALIZED_CHANGED_ON, startDttm: CHANGED_ON_TIMESTAMP, state: 'running', cached: false, sqlEditorId: 'dfsadfs', }, }; newState = sqlLabReducer(newState, action); expect(Object.keys(newState.queries)).toHaveLength(1); }); test('should stop the query', () => { const startQueryAction = { type: actions.START_QUERY, query, }; newState = sqlLabReducer(newState, startQueryAction); const stopQueryAction = { type: actions.STOP_QUERY, query, }; newState = sqlLabReducer(newState, stopQueryAction); const q = newState.queries[Object.keys(newState.queries)[0]]; expect(q.state).toBe('stopped'); }); test('should remove a query', () => { const startQueryAction = { type: actions.START_QUERY, query, }; newState = sqlLabReducer(newState, startQueryAction); const removeQueryAction = { type: actions.REMOVE_QUERY, query, }; newState = sqlLabReducer(newState, removeQueryAction); expect(Object.keys(newState.queries)).toHaveLength(0); }); test('should refresh queries when polling returns new results', () => { const startDttmInStr = '1693433503447.166992'; const endDttmInStr = '1693433503500.23132'; newState = sqlLabReducer( { ...newState, queries: { abcd: {} }, }, actions.refreshQueries({ abcd: { ...query, startDttm: startDttmInStr, endDttm: endDttmInStr, }, }), ); expect(newState.queries.abcd.changed_on).toBe(DENORMALIZED_CHANGED_ON); expect(newState.queries.abcd.startDttm).toBe(Number(startDttmInStr)); expect(newState.queries.abcd.endDttm).toBe(Number(endDttmInStr)); expect(newState.queriesLastUpdate).toBe(CHANGED_ON_TIMESTAMP); }); test('should skip refreshing queries when polling contains existing results', () => { const completedQuery = { ...query, extra: { columns: [], progress: null, }, }; newState = sqlLabReducer( { ...newState, queries: { abcd: query, def: completedQuery }, }, actions.refreshQueries({ abcd: { ...query, }, def: { ...completedQuery, extra: { columns: [], progress: null, }, }, }), ); expect(newState.queries.abcd).toBe(query); expect(newState.queries.def).toBe(completedQuery); }); test('should refresh queries when polling returns empty', () => { const prevQueries = newState.queries; newState = sqlLabReducer(newState, actions.refreshQueries({})); // Empty refresh should preserve existing queries expect(newState.queries).toBe(prevQueries); }); test('should set state to fetching when sync query succeeds without results', () => { const syncQuery = { ...query, id: 'sync-query', state: QueryState.Pending, runAsync: false, results: null, }; newState = sqlLabReducer( { ...newState, queries: { 'sync-query': syncQuery }, }, actions.refreshQueries({ 'sync-query': { ...syncQuery, state: QueryState.Success, }, }), ); expect(newState.queries['sync-query'].state).toBe(QueryState.Fetching); }); test('should move an async query from running to success when polling reports it finished', () => { const asyncQuery = { ...query, id: 'async-query', state: QueryState.Running, runAsync: true, }; newState = sqlLabReducer( { ...newState, queries: { 'async-query': asyncQuery }, }, actions.refreshQueries({ 'async-query': { ...asyncQuery, state: QueryState.Success, }, }), ); expect(newState.queries['async-query'].state).toBe(QueryState.Success); }); test('should downgrade a premature poller success to fetching for a running sync query', () => { const syncQuery = { ...query, id: 'sync-running', state: QueryState.Running, runAsync: false, results: null, }; newState = sqlLabReducer( { ...newState, queries: { 'sync-running': syncQuery }, }, actions.refreshQueries({ 'sync-running': { ...syncQuery, state: QueryState.Success, }, }), ); expect(newState.queries['sync-running'].state).toBe(QueryState.Fetching); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('CLEAR_INACTIVE_QUERIES', () => { let newState: any; let query: any; beforeEach(() => { query = { id: 'abcd', changed_on: Date.now(), startDttm: Date.now(), state: QueryState.Fetching, progress: 100, resultsKey: 'fa3dccc4-c549-4fbf-93c8-b4fb5a6fb8b7', cached: false, }; }); test('updates queries that have already been completed', () => { newState = sqlLabReducer( { ...newState, queries: { abcd: { ...query, results: { query_id: 1234, status: QueryState.Success, data: [], }, }, }, }, actions.clearInactiveQueries(Date.now()), ); expect(newState.queries.abcd.state).toBe(QueryState.Success); }); }); });