airbnb/caravel

View on GitHub
superset-frontend/src/SqlLab/reducers/getInitialState.ts

Summary

Maintainability
B
6 hrs
Test Coverage
/**
 * 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 { t } from '@superset-ui/core';
import type { BootstrapData } from 'src/types/bootstrapTypes';
import type { InitialState } from 'src/hooks/apiResources/sqlLab';
import {
  QueryEditor,
  UnsavedQueryEditor,
  SqlLabRootState,
  Table,
  LatestQueryEditorVersion,
  QueryEditorVersion,
} from 'src/SqlLab/types';

export function dedupeTabHistory(tabHistory: string[]) {
  return tabHistory.reduce<string[]>(
    (result, tabId) =>
      result.slice(-1)[0] === tabId ? result : result.concat(tabId),
    [],
  );
}

export default function getInitialState({
  common,
  active_tab: activeTab,
  tab_state_ids: tabStateIds = [],
  databases,
  queries: queries_,
  ...otherBootstrapData
}: BootstrapData & Partial<InitialState>) {
  /**
   * Before YYYY-MM-DD, the state for SQL Lab was stored exclusively in the
   * browser's localStorage. The feature flag `SQLLAB_BACKEND_PERSISTENCE`
   * moves the state to the backend instead, migrating it from local storage.
   *
   * To allow for a transparent migration, the initial state is a combination
   * of the backend state (if any) with the browser state (if any).
   */
  let queryEditors: Record<string, QueryEditor> = {};
  const defaultQueryEditor = {
    version: LatestQueryEditorVersion,
    loaded: true,
    name: t('Untitled query'),
    sql: '',
    latestQueryId: null,
    autorun: false,
    dbId: common.conf.SQLLAB_DEFAULT_DBID,
    queryLimit: common.conf.DEFAULT_SQLLAB_LIMIT,
    hideLeftBar: false,
    remoteId: null,
    cursorPosition: { row: 0, column: 0 },
  };
  let unsavedQueryEditor: UnsavedQueryEditor = {};

  /**
   * Load state from the backend. This will be empty if the feature flag
   * `SQLLAB_BACKEND_PERSISTENCE` is off.
   */
  tabStateIds.forEach(({ id, label }) => {
    let queryEditor: QueryEditor;
    if (activeTab && activeTab.id === id) {
      queryEditor = {
        version: activeTab.extra_json?.version ?? QueryEditorVersion.V1,
        id: id.toString(),
        loaded: true,
        name: activeTab.label,
        sql: activeTab.sql || '',
        selectedText: undefined,
        latestQueryId: activeTab.latest_query
          ? activeTab.latest_query.id
          : null,
        remoteId: activeTab.saved_query?.id || null,
        autorun: Boolean(activeTab.autorun),
        templateParams: activeTab.template_params || undefined,
        dbId: activeTab.database_id,
        catalog: activeTab.catalog,
        schema: activeTab.schema,
        queryLimit: activeTab.query_limit,
        hideLeftBar: activeTab.hide_left_bar,
        updatedAt: activeTab.extra_json?.updatedAt,
      };
    } else {
      // dummy state, actual state will be loaded on tab switch
      queryEditor = {
        ...defaultQueryEditor,
        id: id.toString(),
        loaded: false,
        name: label,
        dbId: undefined,
      };
    }
    queryEditors = {
      ...queryEditors,
      [queryEditor.id]: queryEditor,
    };
  });
  const tabHistory = activeTab ? [activeTab.id.toString()] : [];
  let lastUpdatedActiveTab = activeTab ? activeTab.id.toString() : '';
  let tables = {} as Record<string, Table>;
  let editorTabLastUpdatedAt = Date.now();
  if (activeTab) {
    editorTabLastUpdatedAt =
      activeTab.extra_json?.updatedAt || editorTabLastUpdatedAt;
    activeTab.table_schemas
      .filter(tableSchema => tableSchema.description !== null)
      .forEach(tableSchema => {
        const { dataPreviewQueryId, ...persistData } = tableSchema.description;
        const table = {
          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,
          persistData,
          initialized: true,
        };
        tables = {
          ...tables,
          [table.id]: table,
        };
      });
  }

  const queries = {
    ...queries_,
    ...(activeTab?.latest_query && {
      [activeTab.latest_query.id]: activeTab.latest_query,
    }),
  };

  const destroyedQueryEditors = {};

  /**
   * If the `SQLLAB_BACKEND_PERSISTENCE` feature flag is off, or if the user
   * hasn't used SQL Lab after it has been turned on, the state will be stored
   * in the browser's local storage.
   */
  try {
    const localStorageData = localStorage.getItem('redux');
    const sqlLabCacheData = localStorageData
      ? (JSON.parse(localStorageData) as Pick<SqlLabRootState, 'sqlLab'>)
      : undefined;
    if (localStorageData && sqlLabCacheData?.sqlLab) {
      const { sqlLab } = sqlLabCacheData;

      if (sqlLab.queryEditors.length === 0) {
        // migration was successful
        localStorage.removeItem('redux');
      } else {
        unsavedQueryEditor = sqlLab.unsavedQueryEditor || unsavedQueryEditor;
        // add query editors and tables to state with a special flag so they can
        // be migrated if the `SQLLAB_BACKEND_PERSISTENCE` feature flag is on
        sqlLab.queryEditors.forEach(qe => {
          const hasConflictFromBackend = Boolean(queryEditors[qe.id]);
          const unsavedUpdatedAt = queryEditors[qe.id]?.updatedAt;
          const hasUnsavedUpdateSinceLastSave =
            qe.updatedAt &&
            (!unsavedUpdatedAt || qe.updatedAt > unsavedUpdatedAt);
          const cachedQueryEditor: UnsavedQueryEditor =
            !hasConflictFromBackend || hasUnsavedUpdateSinceLastSave ? qe : {};
          queryEditors = {
            ...queryEditors,
            [qe.id]: {
              ...queryEditors[qe.id],
              ...cachedQueryEditor,
              name:
                cachedQueryEditor.title ||
                cachedQueryEditor.name ||
                queryEditors[qe.id]?.name,
              ...(cachedQueryEditor.id &&
                unsavedQueryEditor.id === qe.id &&
                unsavedQueryEditor),
              inLocalStorage: !hasConflictFromBackend,
              loaded: true,
            },
          };
        });
        const expandedTables = new Set();

        if (sqlLab.tables) {
          tables = sqlLab.tables.reduce((merged, table) => {
            const expanded = !expandedTables.has(table.queryEditorId);
            if (expanded) {
              expandedTables.add(table.queryEditorId);
            }
            return {
              ...merged,
              [table.id]: {
                ...tables[table.id],
                ...table,
                expanded,
                inLocalStorage: true,
              },
            };
          }, tables);
        }
        if (sqlLab.queries) {
          Object.values(sqlLab.queries).forEach(query => {
            queries[query.id] = { ...query, inLocalStorage: true };
          });
        }
        if (sqlLab.tabHistory) {
          tabHistory.push(...sqlLab.tabHistory);
        }
        lastUpdatedActiveTab = tabHistory.slice(tabHistory.length - 1)[0] || '';

        if (sqlLab.destroyedQueryEditors) {
          Object.entries(sqlLab.destroyedQueryEditors).forEach(([id, ts]) => {
            if (queryEditors[id]) {
              destroyedQueryEditors[id] = ts;
              delete queryEditors[id];
            }
          });
        }
      }
    }
  } catch (error) {
    // continue regardless of error
  }

  return {
    sqlLab: {
      activeSouthPaneTab: 'Results',
      alerts: [],
      databases,
      offline: false,
      queries: Object.fromEntries(
        Object.entries(queries).map(([queryId, query]) => [
          queryId,
          {
            ...query,
            ...(query.startDttm && {
              startDttm: Number(query.startDttm),
            }),
            ...(query.endDttm && {
              endDttm: Number(query.endDttm),
            }),
          },
        ]),
      ),
      queryEditors: Object.values(queryEditors),
      tabHistory: dedupeTabHistory(tabHistory),
      tables: Object.values(tables),
      queriesLastUpdate: Date.now(),
      editorTabLastUpdatedAt,
      queryCostEstimates: {},
      unsavedQueryEditor,
      lastUpdatedActiveTab,
      destroyedQueryEditors,
    },
    localStorageUsageInKilobytes: 0,
    common,
    ...otherBootstrapData,
  };
}