/** * 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, SupersetTheme, FeatureFlag, isFeatureEnabled, } from '@superset-ui/core'; import React, { FunctionComponent, useEffect, useState, useReducer, Reducer, } from 'react'; import Tabs from 'src/components/Tabs'; import { Select } from 'src/common/components'; import Alert from 'src/components/Alert'; import Modal from 'src/components/Modal'; import Button from 'src/components/Button'; import IconButton from 'src/components/IconButton'; import InfoTooltip from 'src/components/InfoTooltip'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import { testDatabaseConnection, useSingleViewResource, useAvailableDatabases, useDatabaseValidation, getDatabaseImages, getConnectionAlert, } from 'src/views/CRUD/hooks'; import { useCommonConf } from 'src/views/CRUD/data/database/state'; import { DatabaseObject, DatabaseForm, CONFIGURATION_METHOD, CatalogObject, } from 'src/views/CRUD/data/database/types'; import Loading from 'src/components/Loading'; import ExtraOptions from './ExtraOptions'; import SqlAlchemyForm from './SqlAlchemyForm'; import DatabaseConnectionForm from './DatabaseConnectionForm'; import { antDErrorAlertStyles, antDAlertStyles, StyledAlertMargin, antDModalNoPaddingStyles, antDModalStyles, antDTabsStyles, buttonLinkStyles, alchemyButtonLinkStyles, TabHeader, formHelperStyles, formStyles, StyledAlignment, SelectDatabaseStyles, infoTooltip, StyledFooterButton, StyledStickyHeader, } from './styles'; import ModalHeader, { DOCUMENTATION_LINK } from './ModalHeader'; const errorAlertMapping = { CONNECTION_MISSING_PARAMETERS_ERROR: { message: 'Missing Required Fields', description: 'Please complete all required fields.', }, CONNECTION_INVALID_HOSTNAME_ERROR: { message: 'Could not verify the host', description: 'The host is invalid. Please verify that this field is entered correctly.', }, CONNECTION_PORT_CLOSED_ERROR: { message: 'Port is closed', description: 'Please verify that port is open to connect.', }, CONNECTION_INVALID_PORT_ERROR: { message: 'Invalid Port Number', description: 'The port must be a whole number less than or equal to 65535.', }, CONNECTION_ACCESS_DENIED_ERROR: { message: 'Invalid account information', description: 'Either the username or password is incorrect.', }, CONNECTION_INVALID_PASSWORD_ERROR: { message: 'Invalid account information', description: 'Either the username or password is incorrect.', }, INVALID_PAYLOAD_SCHEMA_ERROR: { message: 'Incorrect Fields', description: 'Please make sure all fields are filled out correctly', }, TABLE_DOES_NOT_EXIST_ERROR: { message: 'URL could not be identified', description: 'The URL could not be identified. Please check for typos and make sure that "Type of google sheet allowed" selection matches the input', }, }; interface DatabaseModalProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; onDatabaseAdd?: (database?: DatabaseObject) => void; // TODO: should we add a separate function for edit? onHide: () => void; show: boolean; databaseId: number | undefined; // If included, will go into edit mode } enum ActionType { configMethodChange, dbSelected, editorChange, fetched, inputChange, parametersChange, reset, textChange, extraInputChange, extraEditorChange, addTableCatalogSheet, removeTableCatalogSheet, } interface DBReducerPayloadType { target?: string; name: string; json?: {}; type?: string; checked?: boolean; value?: string; } type DBReducerActionType = | { type: | ActionType.extraEditorChange | ActionType.extraInputChange | ActionType.textChange | ActionType.inputChange | ActionType.editorChange | ActionType.parametersChange; payload: DBReducerPayloadType; } | { type: ActionType.fetched; payload: Partial; } | { type: ActionType.dbSelected; payload: { database_name?: string; engine?: string; configuration_method: CONFIGURATION_METHOD; }; } | { type: ActionType.reset | ActionType.addTableCatalogSheet; } | { type: ActionType.removeTableCatalogSheet; payload: { indexToDelete: number; }; } | { type: ActionType.configMethodChange; payload: { database_name?: string; engine?: string; configuration_method: CONFIGURATION_METHOD; }; }; function dbReducer( state: Partial | null, action: DBReducerActionType, ): Partial | null { const trimmedState = { ...(state || {}), }; let query = ''; let deserializeExtraJSON = {}; let extra_json: DatabaseObject['extra_json']; switch (action.type) { case ActionType.extraEditorChange: return { ...trimmedState, extra_json: { ...trimmedState.extra_json, [action.payload.name]: action.payload.json, }, }; case ActionType.extraInputChange: if ( action.payload.name === 'schema_cache_timeout' || action.payload.name === 'table_cache_timeout' ) { return { ...trimmedState, extra_json: { ...trimmedState.extra_json, metadata_cache_timeout: { ...trimmedState.extra_json?.metadata_cache_timeout, [action.payload.name]: action.payload.value, }, }, }; } return { ...trimmedState, extra_json: { ...trimmedState.extra_json, [action.payload.name]: action.payload.type === 'checkbox' ? action.payload.checked : action.payload.value, }, }; case ActionType.inputChange: if (action.payload.type === 'checkbox') { return { ...trimmedState, [action.payload.name]: action.payload.checked, }; } return { ...trimmedState, [action.payload.name]: action.payload.value, }; case ActionType.parametersChange: if ( trimmedState.catalog !== undefined && action.payload.type?.startsWith('catalog') ) { // Formatting wrapping google sheets table catalog const idx = action.payload.type?.split('-')[1]; const catalogToUpdate = trimmedState?.catalog[idx] || {}; catalogToUpdate[action.payload.name] = action.payload.value; const paramatersCatalog = {}; // eslint-disable-next-line array-callback-return trimmedState.catalog?.map((item: CatalogObject) => { paramatersCatalog[item.name] = item.value; }); return { ...trimmedState, parameters: { ...trimmedState.parameters, catalog: paramatersCatalog, }, }; } return { ...trimmedState, parameters: { ...trimmedState.parameters, [action.payload.name]: action.payload.value, }, }; case ActionType.addTableCatalogSheet: if (trimmedState.catalog !== undefined) { return { ...trimmedState, catalog: [...trimmedState.catalog, { name: '', value: '' }], }; } return { ...trimmedState, catalog: [{ name: '', value: '' }], }; case ActionType.removeTableCatalogSheet: trimmedState.catalog?.splice(action.payload.indexToDelete, 1); return { ...trimmedState, }; case ActionType.editorChange: return { ...trimmedState, [action.payload.name]: action.payload.json, }; case ActionType.textChange: return { ...trimmedState, [action.payload.name]: action.payload.value, }; case ActionType.fetched: // convert all the keys in this payload into strings if (action.payload.extra) { extra_json = { ...JSON.parse(action.payload.extra || ''), } as DatabaseObject['extra_json']; deserializeExtraJSON = { ...JSON.parse(action.payload.extra || ''), metadata_params: JSON.stringify(extra_json?.metadata_params), engine_params: JSON.stringify(extra_json?.engine_params), schemas_allowed_for_csv_upload: extra_json?.schemas_allowed_for_csv_upload, }; } if ( action.payload.backend === 'bigquery' && action.payload.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM ) { return { ...action.payload, engine: action.payload.backend, configuration_method: action.payload.configuration_method, extra_json: deserializeExtraJSON, parameters: { query, credentials_info: JSON.stringify( action.payload?.parameters?.credentials_info || '', ), }, }; } if ( action.payload.backend === 'gsheets' && action.payload.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM && extra_json?.engine_params?.catalog !== undefined ) { // pull catalog from engine params const engineParamsCatalog = extra_json?.engine_params?.catalog; return { ...action.payload, engine: action.payload.backend, configuration_method: action.payload.configuration_method, extra_json: deserializeExtraJSON, catalog: Object.keys(engineParamsCatalog).map(e => ({ name: e, value: engineParamsCatalog[e], })), } as DatabaseObject; } if (action.payload?.parameters?.query) { // convert query into URI params string query = new URLSearchParams( action.payload.parameters.query as string, ).toString(); return { ...action.payload, encrypted_extra: action.payload.encrypted_extra || '', engine: action.payload.backend || trimmedState.engine, configuration_method: action.payload.configuration_method, extra_json: deserializeExtraJSON, parameters: { ...action.payload.parameters, query, }, }; } return { ...action.payload, encrypted_extra: action.payload.encrypted_extra || '', engine: action.payload.backend || trimmedState.engine, configuration_method: action.payload.configuration_method, extra_json: deserializeExtraJSON, parameters: { ...action.payload.parameters, }, }; case ActionType.dbSelected: return { ...action.payload, }; case ActionType.configMethodChange: return { ...action.payload, }; case ActionType.reset: default: return null; } } const DEFAULT_TAB_KEY = '1'; const serializeExtra = (extraJson: DatabaseObject['extra_json']) => JSON.stringify({ ...extraJson, metadata_params: JSON.parse((extraJson?.metadata_params as string) || '{}'), engine_params: JSON.parse( ((extraJson?.engine_params as unknown) as string) || '{}', ), schemas_allowed_for_csv_upload: (extraJson?.schemas_allowed_for_csv_upload as string) || '[]', }); const DatabaseModal: FunctionComponent = ({ addDangerToast, addSuccessToast, onDatabaseAdd, onHide, show, databaseId, }) => { const [db, setDB] = useReducer< Reducer | null, DBReducerActionType> >(dbReducer, null); const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY); const [availableDbs, getAvailableDbs] = useAvailableDatabases(); const [ validationErrors, getValidation, setValidationErrors, ] = useDatabaseValidation(); const [hasConnectedDb, setHasConnectedDb] = useState(false); const [dbName, setDbName] = useState(''); const [editNewDb, setEditNewDb] = useState(false); const [isLoading, setLoading] = useState(false); const conf = useCommonConf(); const dbImages = getDatabaseImages(); const connectionAlert = getConnectionAlert(); const isEditMode = !!databaseId; const sslForced = isFeatureEnabled( FeatureFlag.FORCE_DATABASE_CONNECTIONS_SSL, ); const useSqlAlchemyForm = db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI; const useTabLayout = isEditMode || useSqlAlchemyForm; // Database fetch logic const { state: { loading: dbLoading, resource: dbFetched, error: dbErrors }, fetchResource, createResource, updateResource, clearError, } = useSingleViewResource( 'database', t('database'), addDangerToast, ); const isDynamic = (engine: string | undefined) => availableDbs?.databases.filter( (DB: DatabaseObject) => DB.backend === engine || DB.engine === engine, )[0].parameters !== undefined; const showDBError = validationErrors || dbErrors; const isEmpty = (data?: Object | null) => data && Object.keys(data).length === 0; const dbModel: DatabaseForm = availableDbs?.databases?.find( (available: { engine: string | undefined }) => // TODO: we need a centralized engine in one place available.engine === (isEditMode ? db?.backend : db?.engine), ) || {}; // Test Connection logic const testConnection = () => { if (!db?.sqlalchemy_uri) { addDangerToast(t('Please enter a SQLAlchemy URI to test')); return; } const connection = { sqlalchemy_uri: db?.sqlalchemy_uri || '', database_name: db?.database_name?.trim() || undefined, impersonate_user: db?.impersonate_user || undefined, extra: serializeExtra(db?.extra_json) || undefined, encrypted_extra: db?.encrypted_extra || '', server_cert: db?.server_cert || undefined, }; testDatabaseConnection(connection, addDangerToast, addSuccessToast); }; const onClose = () => { setDB({ type: ActionType.reset }); setHasConnectedDb(false); setValidationErrors(null); // reset validation errors on close clearError(); setEditNewDb(false); onHide(); }; const onSave = async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id, ...update } = db || {}; // Clone DB object const dbToUpdate = JSON.parse(JSON.stringify(update)); if (dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM) { // Validate DB before saving await getValidation(dbToUpdate, true); if (validationErrors && !isEmpty(validationErrors)) { return; } if (dbToUpdate?.parameters?.query) { // convert query params into dictionary dbToUpdate.parameters.query = JSON.parse( `{"${decodeURI((dbToUpdate?.parameters?.query as string) || '') .replace(/"/g, '\\"') .replace(/&/g, '","') .replace(/=/g, '":"')}"}`, ); } else if ( dbToUpdate?.parameters?.query === '' && 'query' in dbModel.parameters.properties ) { dbToUpdate.parameters.query = {}; } const engine = dbToUpdate.backend || dbToUpdate.engine; if (engine === 'bigquery' && dbToUpdate.parameters?.credentials_info) { // wrap encrypted_extra in credentials_info only for BigQuery if ( dbToUpdate.parameters?.credentials_info && typeof dbToUpdate.parameters?.credentials_info === 'object' && dbToUpdate.parameters?.credentials_info.constructor === Object ) { // Don't cast if object dbToUpdate.encrypted_extra = JSON.stringify({ credentials_info: dbToUpdate.parameters?.credentials_info, }); // Convert credentials info string before updating dbToUpdate.parameters.credentials_info = JSON.stringify( dbToUpdate.parameters.credentials_info, ); } else { dbToUpdate.encrypted_extra = JSON.stringify({ credentials_info: JSON.parse( dbToUpdate.parameters?.credentials_info, ), }); } } } if (dbToUpdate?.parameters?.catalog) { // need to stringify gsheets catalog to allow it to be seralized dbToUpdate.extra_json = { engine_params: JSON.stringify({ catalog: dbToUpdate.parameters.catalog, }), }; } if (dbToUpdate?.extra_json) { // convert extra_json to back to string dbToUpdate.extra = serializeExtra(dbToUpdate?.extra_json); } if (db?.id) { setLoading(true); const result = await updateResource( db.id as number, dbToUpdate as DatabaseObject, dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM, // onShow toast on SQLA Forms ); if (result) { if (onDatabaseAdd) { onDatabaseAdd(); } if (!editNewDb) { onClose(); } } } else if (db) { // Create setLoading(true); const dbId = await createResource( dbToUpdate as DatabaseObject, dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM, // onShow toast on SQLA Forms ); if (dbId) { setHasConnectedDb(true); if (onDatabaseAdd) { onDatabaseAdd(); } if (useTabLayout) { // tab layout only has one step // so it should close immediately on save onClose(); } } } setEditNewDb(false); setLoading(false); }; const onChange = (type: any, payload: any) => { setDB({ type, payload } as DBReducerActionType); }; // Initialize const fetchDB = () => { if (isEditMode && databaseId) { if (!dbLoading) { fetchResource(databaseId).catch(e => addDangerToast( t( 'Sorry there was an error fetching database information: %s', e.message, ), ), ); } } }; const setDatabaseModel = (database_name: string) => { const selectedDbModel = availableDbs?.databases.filter( (db: DatabaseObject) => db.name === database_name, )[0]; const { engine, parameters } = selectedDbModel; const isDynamic = parameters !== undefined; setDB({ type: ActionType.dbSelected, payload: { database_name, configuration_method: isDynamic ? CONFIGURATION_METHOD.DYNAMIC_FORM : CONFIGURATION_METHOD.SQLALCHEMY_URI, engine, }, }); setDB({ type: ActionType.addTableCatalogSheet }); }; const renderAvailableSelector = () => (

Or choose from a list of other databases we support:

Supported databases
antDAlertStyles(theme)} type="info" message={ connectionAlert?.ADD_DATABASE?.message || t('Want to add a new database?') } description={ connectionAlert?.ADD_DATABASE ? ( <> Any databases that allow connections via SQL Alchemy URIs can be added.{' '} {connectionAlert?.ADD_DATABASE.contact_description_link} {' '} {connectionAlert?.ADD_DATABASE.description} ) : ( <> Any databases that allow connections via SQL Alchemy URIs can be added. Learn about how to connect a database driver{' '} here . ) } />
); const renderPreferredSelector = () => (
{availableDbs?.databases ?.filter((db: DatabaseForm) => db.preferred) .map((database: DatabaseForm) => ( setDatabaseModel(database.name)} buttonText={database.name} icon={dbImages?.[database.engine]} /> ))}
); const handleBackButtonOnFinish = () => { if (dbFetched) { fetchResource(dbFetched.id as number); } setEditNewDb(true); }; const handleBackButtonOnConnect = () => { if (editNewDb) { setHasConnectedDb(false); } setDB({ type: ActionType.reset }); }; const renderModalFooter = () => { if (db) { // if db show back + connenct if (!hasConnectedDb || editNewDb) { return ( <> Back Connect ); } return ( <> Back Finish ); } return []; }; const renderEditModalFooter = () => ( <> Close Finish ); useEffect(() => { if (show) { setTabKey(DEFAULT_TAB_KEY); getAvailableDbs(); setLoading(true); } if (databaseId && show) { fetchDB(); } }, [show, databaseId]); useEffect(() => { if (dbFetched) { setDB({ type: ActionType.fetched, payload: dbFetched, }); // keep a copy of the name separate for display purposes // because it shouldn't change when the form is updated setDbName(dbFetched.database_name); } }, [dbFetched]); useEffect(() => { if (isLoading) { setLoading(false); } }, [availableDbs]); const tabChange = (key: string) => { setTabKey(key); }; const errorAlert = () => { if ( isEmpty(dbErrors) || (isEmpty(validationErrors) && !(validationErrors?.error_type in errorAlertMapping)) ) { return <>; } if (validationErrors) { return ( antDErrorAlertStyles(theme)} message={ errorAlertMapping[validationErrors?.error_type]?.message || validationErrors?.error_type } description={ errorAlertMapping[validationErrors?.error_type]?.description || JSON.stringify(validationErrors) } showIcon closable={false} /> ); } const message: Array = Object.values(dbErrors); return ( antDErrorAlertStyles(theme)} message="Database Creation Error" description={message[0]} /> ); }; const renderFinishState = () => { if (!editNewDb) { return ( onChange(ActionType.inputChange, { type: target.type, name: target.name, checked: target.checked, value: target.value, }) } onTextChange={({ target }: { target: HTMLTextAreaElement }) => onChange(ActionType.textChange, { name: target.name, value: target.value, }) } onEditorChange={(payload: { name: string; json: any }) => onChange(ActionType.editorChange, payload) } onExtraInputChange={({ target }: { target: HTMLInputElement }) => { onChange(ActionType.extraInputChange, { type: target.type, name: target.name, checked: target.checked, value: target.value, }); }} onExtraEditorChange={(payload: { name: string; json: any }) => onChange(ActionType.extraEditorChange, payload) } /> ); } return ( onChange(ActionType.parametersChange, { type: target.type, name: target.name, checked: target.checked, value: target.value, }) } onChange={({ target }: { target: HTMLInputElement }) => onChange(ActionType.textChange, { name: target.name, value: target.value, }) } onAddTableCatalog={() => setDB({ type: ActionType.addTableCatalogSheet }) } onRemoveTableCatalog={(idx: number) => setDB({ type: ActionType.removeTableCatalogSheet, payload: { indexToDelete: idx }, }) } getValidation={() => getValidation(db)} validationErrors={validationErrors} /> ); }; return useTabLayout ? ( [ antDTabsStyles, antDModalNoPaddingStyles, antDModalStyles(theme), formHelperStyles(theme), formStyles(theme), ]} name="database" data-test="database-modal" onHandledPrimaryAction={onSave} onHide={onClose} primaryButtonName={isEditMode ? t('Save') : t('Connect')} width="500px" centered show={show} title={

{isEditMode ? t('Edit database') : t('Connect a database')}

} footer={isEditMode ? renderEditModalFooter() : renderModalFooter()} > {t('Basic')}} key="1"> {useSqlAlchemyForm ? ( onChange(ActionType.inputChange, { type: target.type, name: target.name, checked: target.checked, value: target.value, }) } conf={conf} testConnection={testConnection} isEditMode={isEditMode} /> {isDynamic(db?.backend || db?.engine) && !isEditMode && (
infoTooltip(theme)}>
)}
) : ( onChange(ActionType.parametersChange, { type: target.type, name: target.name, checked: target.checked, value: target.value, }) } onChange={({ target }: { target: HTMLInputElement }) => onChange(ActionType.textChange, { name: target.name, value: target.value, }) } onAddTableCatalog={() => setDB({ type: ActionType.addTableCatalogSheet }) } onRemoveTableCatalog={(idx: number) => setDB({ type: ActionType.removeTableCatalogSheet, payload: { indexToDelete: idx }, }) } getValidation={() => getValidation(db)} validationErrors={validationErrors} /> )} {!isEditMode && ( antDAlertStyles(theme)} message="Additional fields may be required" showIcon description={ <> Select databases require additional fields to be completed in the Advanced tab to successfully connect the database. Learn what requirements your databases has{' '} here . } type="info" /> )}
{t('Advanced')}} key="2"> onChange(ActionType.inputChange, { type: target.type, name: target.name, checked: target.checked, value: target.value, }) } onTextChange={({ target }: { target: HTMLTextAreaElement }) => onChange(ActionType.textChange, { name: target.name, value: target.value, }) } onEditorChange={(payload: { name: string; json: any }) => onChange(ActionType.editorChange, payload) } onExtraInputChange={({ target }: { target: HTMLInputElement }) => { onChange(ActionType.extraInputChange, { type: target.type, name: target.name, checked: target.checked, value: target.value, }); }} onExtraEditorChange={(payload: { name: string; json: any }) => { onChange(ActionType.extraEditorChange, payload); }} /> {showDBError && errorAlert()}
) : ( [ antDModalNoPaddingStyles, antDModalStyles(theme), formHelperStyles(theme), formStyles(theme), ]} name="database" onHandledPrimaryAction={onSave} onHide={onClose} primaryButtonName={hasConnectedDb ? t('Finish') : t('Connect')} width="500px" centered show={show} title={

{t('Connect a database')}

} footer={renderModalFooter()} > {hasConnectedDb ? ( <> {renderFinishState()} ) : ( <> {/* Dyanmic Form Step 1 */} {!isLoading && (!db ? ( {renderPreferredSelector()} {renderAvailableSelector()} ) : ( <> {connectionAlert && ( antDAlertStyles(theme)} type="info" showIcon message={t('IP Allowlist')} description={connectionAlert.ALLOWED_IPS} /> )} { setDB({ type: ActionType.addTableCatalogSheet }); }} onRemoveTableCatalog={(idx: number) => { setDB({ type: ActionType.removeTableCatalogSheet, payload: { indexToDelete: idx }, }); }} onParametersChange={({ target, }: { target: HTMLInputElement; }) => onChange(ActionType.parametersChange, { type: target.type, name: target.name, checked: target.checked, value: target.value, }) } onChange={({ target }: { target: HTMLInputElement }) => onChange(ActionType.textChange, { name: target.name, value: target.value, }) } getValidation={() => getValidation(db)} validationErrors={validationErrors} />
infoTooltip(theme)}>
{/* Step 2 */} {showDBError && errorAlert()} ))} )} {isLoading && }
); }; export default withToasts(DatabaseModal);