/** * 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. */ /* eslint-disable jsx-a11y/anchor-is-valid */ /* eslint-disable jsx-a11y/no-static-element-interactions */ import React from 'react'; import { CSSTransition } from 'react-transition-group'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; import { Form } from 'react-bootstrap'; import Split from 'react-split'; import { t, styled, supersetTheme } from '@superset-ui/core'; import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; import StyledModal from 'src/common/components/Modal'; import Mousetrap from 'mousetrap'; import Button from 'src/components/Button'; import Timer from 'src/components/Timer'; import { Dropdown, Menu as AntdMenu, Menu, Switch, Input, } from 'src/common/components'; import Icon from 'src/components/Icon'; import { detectOS } from 'src/utils/common'; import { addQueryEditor, CtasEnum, estimateQueryCost, persistEditorHeight, postStopQuery, queryEditorSetAutorun, queryEditorSetQueryLimit, queryEditorSetSql, queryEditorSetTemplateParams, runQuery, saveQuery, scheduleQuery, setActiveSouthPaneTab, updateSavedQuery, validateQuery, } from '../actions/sqlLab'; import TemplateParamsEditor from './TemplateParamsEditor'; import ConnectedSouthPane from './SouthPane'; import SaveQuery from './SaveQuery'; import ScheduleQueryButton from './ScheduleQueryButton'; import EstimateQueryCostButton from './EstimateQueryCostButton'; import ShareSqlLabQuery from './ShareSqlLabQuery'; import SqlEditorLeftBar from './SqlEditorLeftBar'; import AceEditorWrapper from './AceEditorWrapper'; import { STATE_TYPE_MAP, SQL_EDITOR_GUTTER_HEIGHT, SQL_EDITOR_GUTTER_MARGIN, SQL_TOOLBAR_HEIGHT, } from '../constants'; import RunQueryActionButton from './RunQueryActionButton'; import { FeatureFlag, isFeatureEnabled } from '../../featureFlags'; const LIMIT_DROPDOWN = [10, 100, 1000, 10000, 100000]; const SQL_EDITOR_PADDING = 10; const INITIAL_NORTH_PERCENT = 30; const INITIAL_SOUTH_PERCENT = 70; const SET_QUERY_EDITOR_SQL_DEBOUNCE_MS = 2000; const VALIDATION_DEBOUNCE_MS = 600; const WINDOW_RESIZE_THROTTLE_MS = 100; const LimitSelectStyled = styled.span` .ant-dropdown-trigger { align-items: center; color: black; display: flex; font-size: 12px; margin-right: ${({ theme }) => theme.gridUnit * 2}px; text-decoration: none; span { display: inline-block; margin-right: ${({ theme }) => theme.gridUnit * 2}px; &:last-of-type: { margin-right: ${({ theme }) => theme.gridUnit * 4}px; } } } `; const StyledToolbar = styled.div` padding: ${({ theme }) => theme.gridUnit * 2}px; background-color: @lightest; display: flex; justify-content: space-between; border: 1px solid ${supersetTheme.colors.grayscale.light2}; border-top: 0; form { margin-block-end: 0; } .leftItems form, .rightItems { display: flex; align-items: center; & > span { margin-right: ${({ theme }) => theme.gridUnit * 2}px; display: inline-block; &:last-child { margin-right: 0; } } } `; const propTypes = { actions: PropTypes.object.isRequired, database: PropTypes.object, latestQuery: PropTypes.object, tables: PropTypes.array.isRequired, editorQueries: PropTypes.array.isRequired, dataPreviewQueries: PropTypes.array.isRequired, queryEditorId: PropTypes.string.isRequired, hideLeftBar: PropTypes.bool, defaultQueryLimit: PropTypes.number.isRequired, maxRow: PropTypes.number.isRequired, displayLimit: PropTypes.number.isRequired, saveQueryWarning: PropTypes.string, scheduleQueryWarning: PropTypes.string, }; const defaultProps = { database: null, latestQuery: null, hideLeftBar: false, scheduleQueryWarning: null, }; class SqlEditor extends React.PureComponent { constructor(props) { super(props); this.state = { autorun: props.queryEditor.autorun, ctas: '', northPercent: props.queryEditor.northPercent || INITIAL_NORTH_PERCENT, southPercent: props.queryEditor.southPercent || INITIAL_SOUTH_PERCENT, sql: props.queryEditor.sql, autocompleteEnabled: true, showCreateAsModal: false, createAs: '', }; this.sqlEditorRef = React.createRef(); this.northPaneRef = React.createRef(); this.elementStyle = this.elementStyle.bind(this); this.onResizeStart = this.onResizeStart.bind(this); this.onResizeEnd = this.onResizeEnd.bind(this); this.canValidateQuery = this.canValidateQuery.bind(this); this.runQuery = this.runQuery.bind(this); this.stopQuery = this.stopQuery.bind(this); this.onSqlChanged = this.onSqlChanged.bind(this); this.setQueryEditorSql = this.setQueryEditorSql.bind(this); this.setQueryEditorSqlWithDebounce = debounce( this.setQueryEditorSql.bind(this), SET_QUERY_EDITOR_SQL_DEBOUNCE_MS, ); this.queryPane = this.queryPane.bind(this); this.renderQueryLimit = this.renderQueryLimit.bind(this); this.getAceEditorAndSouthPaneHeights = this.getAceEditorAndSouthPaneHeights.bind( this, ); this.getSqlEditorHeight = this.getSqlEditorHeight.bind(this); this.requestValidation = debounce( this.requestValidation.bind(this), VALIDATION_DEBOUNCE_MS, ); this.getQueryCostEstimate = this.getQueryCostEstimate.bind(this); this.handleWindowResize = throttle( this.handleWindowResize.bind(this), WINDOW_RESIZE_THROTTLE_MS, ); this.renderDropdown = this.renderDropdown.bind(this); } UNSAFE_componentWillMount() { if (this.state.autorun) { this.setState({ autorun: false }); this.props.queryEditorSetAutorun(this.props.queryEditor, false); this.startQuery(); } } componentDidMount() { // We need to measure the height of the sql editor post render to figure the height of // the south pane so it gets rendered properly // eslint-disable-next-line react/no-did-mount-set-state this.setState({ height: this.getSqlEditorHeight() }); window.addEventListener('resize', this.handleWindowResize); // setup hotkeys const hotkeys = this.getHotkeyConfig(); hotkeys.forEach(keyConfig => { Mousetrap.bind([keyConfig.key], keyConfig.func); }); } componentWillUnmount() { window.removeEventListener('resize', this.handleWindowResize); } onResizeStart() { // Set the heights on the ace editor and the ace content area after drag starts // to smooth out the visual transition to the new heights when drag ends document.getElementsByClassName('ace_content')[0].style.height = '100%'; } onResizeEnd([northPercent, southPercent]) { this.setState({ northPercent, southPercent }); if (this.northPaneRef.current && this.northPaneRef.current.clientHeight) { this.props.persistEditorHeight( this.props.queryEditor, northPercent, southPercent, ); } } onSqlChanged(sql) { this.setState({ sql }); this.setQueryEditorSqlWithDebounce(sql); // Request server-side validation of the query text if (this.canValidateQuery()) { // NB. requestValidation is debounced this.requestValidation(); } } // One layer of abstraction for easy spying in unit tests getSqlEditorHeight() { return this.sqlEditorRef.current ? this.sqlEditorRef.current.clientHeight - SQL_EDITOR_PADDING * 2 : 0; } // Return the heights for the ace editor and the south pane as an object // given the height of the sql editor, north pane percent and south pane percent. getAceEditorAndSouthPaneHeights(height, northPercent, southPercent) { return { aceEditorHeight: (height * northPercent) / 100 - (SQL_EDITOR_GUTTER_HEIGHT / 2 + SQL_EDITOR_GUTTER_MARGIN) - SQL_TOOLBAR_HEIGHT, southPaneHeight: (height * southPercent) / 100 - (SQL_EDITOR_GUTTER_HEIGHT / 2 + SQL_EDITOR_GUTTER_MARGIN), }; } getHotkeyConfig() { // Get the user's OS const userOS = detectOS(); return [ { name: 'runQuery1', key: 'ctrl+r', descr: t('Run query'), func: () => { if (this.state.sql.trim() !== '') { this.runQuery(); } }, }, { name: 'runQuery2', key: 'ctrl+enter', descr: t('Run query'), func: () => { if (this.state.sql.trim() !== '') { this.runQuery(); } }, }, { name: 'newTab', key: userOS === 'Windows' ? 'ctrl+q' : 'ctrl+t', descr: t('New tab'), func: () => { this.props.addQueryEditor({ ...this.props.queryEditor, title: t('Untitled query'), sql: '', }); }, }, { name: 'stopQuery', key: 'ctrl+x', descr: t('Stop query'), func: this.stopQuery, }, ]; } setQueryEditorSql(sql) { this.props.queryEditorSetSql(this.props.queryEditor, sql); } setQueryLimit(queryLimit) { this.props.queryEditorSetQueryLimit(this.props.queryEditor, queryLimit); } getQueryCostEstimate() { if (this.props.database) { const qe = this.props.queryEditor; const query = { dbId: qe.dbId, sql: qe.selectedText ? qe.selectedText : this.state.sql, sqlEditorId: qe.id, schema: qe.schema, templateParams: qe.templateParams, }; this.props.estimateQueryCost(query); } } handleToggleAutocompleteEnabled = () => { this.setState(prevState => ({ autocompleteEnabled: !prevState.autocompleteEnabled, })); }; handleWindowResize() { this.setState({ height: this.getSqlEditorHeight() }); } elementStyle(dimension, elementSize, gutterSize) { return { [dimension]: `calc(${elementSize}% - ${ gutterSize + SQL_EDITOR_GUTTER_MARGIN }px)`, }; } requestValidation() { if (this.props.database) { const qe = this.props.queryEditor; const query = { dbId: qe.dbId, sql: this.state.sql, sqlEditorId: qe.id, schema: qe.schema, templateParams: qe.templateParams, }; this.props.validateQuery(query); } } canValidateQuery() { // Check whether or not we can validate the current query based on whether // or not the backend has a validator configured for it. const validatorMap = window.featureFlags.SQL_VALIDATORS_BY_ENGINE; if (this.props.database && validatorMap != null) { return validatorMap.hasOwnProperty(this.props.database.backend); } return false; } runQuery() { if (this.props.database) { this.startQuery(); } } convertToNumWithSpaces(num) { return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 '); } startQuery(ctas = false, ctas_method = CtasEnum.TABLE) { const qe = this.props.queryEditor; const query = { dbId: qe.dbId, sql: qe.selectedText ? qe.selectedText : this.state.sql, sqlEditorId: qe.id, tab: qe.title, schema: qe.schema, tempTable: ctas ? this.state.ctas : '', templateParams: qe.templateParams, queryLimit: qe.queryLimit || this.props.defaultQueryLimit, runAsync: this.props.database ? this.props.database.allow_run_async : false, ctas, ctas_method, updateTabState: !qe.selectedText, }; this.props.runQuery(query); this.props.setActiveSouthPaneTab('Results'); } stopQuery() { if ( this.props.latestQuery && ['running', 'pending'].indexOf(this.props.latestQuery.state) >= 0 ) { this.props.postStopQuery(this.props.latestQuery); } } createTableAs() { this.startQuery(true, CtasEnum.TABLE); this.setState({ showCreateAsModal: false, ctas: '' }); } createViewAs() { this.startQuery(true, CtasEnum.VIEW); this.setState({ showCreateAsModal: false, ctas: '' }); } ctasChanged(event) { this.setState({ ctas: event.target.value }); } queryPane() { const hotkeys = this.getHotkeyConfig(); const { aceEditorHeight, southPaneHeight, } = this.getAceEditorAndSouthPaneHeights( this.state.height, this.state.northPercent, this.state.southPercent, ); return (
{this.renderEditorBottomBar(hotkeys)}
); } renderDropdown() { const qe = this.props.queryEditor; const successful = this.props.latestQuery?.state === 'success'; const scheduleToolTip = successful ? t('Schedule the query periodically') : t('You must run the query successfully first'); return ( {' '} {t('Autocomplete')}{' '} {' '} {isFeatureEnabled(FeatureFlag.ENABLE_TEMPLATE_PROCESSING) && ( { this.props.actions.queryEditorSetTemplateParams(qe, params); }} code={qe.templateParams} /> )} {isFeatureEnabled(FeatureFlag.SCHEDULED_QUERIES) && ( )} ); } renderQueryLimit() { // Adding SQL_MAX_ROW value to dropdown const { maxRow } = this.props; LIMIT_DROPDOWN.push(maxRow); return ( {[...new Set(LIMIT_DROPDOWN)].map(limit => ( this.setQueryLimit(limit)} > {/* // eslint-disable-line no-use-before-define */} {this.convertToNumWithSpaces(limit)} {' '} ))} ); } renderEditorBottomBar() { const { queryEditor: qe } = this.props; const { allow_ctas: allowCTAS, allow_cvas: allowCVAS } = this.props.database || {}; const showMenu = allowCTAS || allowCVAS; const runMenuBtn = ( {allowCTAS && ( { this.setState({ showCreateAsModal: true, createAs: CtasEnum.TABLE, }); }} key="1" > {t('CREATE TABLE AS')} )} {allowCVAS && ( { this.setState({ showCreateAsModal: true, createAs: CtasEnum.VIEW, }); }} key="2" > {t('CREATE VIEW AS')} )} ); return (
{isFeatureEnabled(FeatureFlag.ESTIMATE_QUERY_COST) && this.props.database && this.props.database.allows_cost_estimate && ( )} e.preventDefault()}> LIMIT: {this.convertToNumWithSpaces( this.props.queryEditor.queryLimit || this.props.defaultQueryLimit, )} {this.props.latestQuery && ( )}
); } render() { const createViewModalTitle = this.state.createAs === CtasEnum.VIEW ? 'CREATE VIEW AS' : 'CREATE TABLE AS'; const createModalPlaceHolder = this.state.createAs === CtasEnum.VIEW ? 'Specify name to CREATE VIEW AS schema in: public' : 'Specify name to CREATE TABLE AS schema in: public'; const leftBarStateClass = this.props.hideLeftBar ? 'schemaPane-exit-done' : 'schemaPane-enter-done'; return (
{this.queryPane()} { this.setState({ showCreateAsModal: false }); }} footer={ <> {this.state.createAs === CtasEnum.TABLE && ( )} {this.state.createAs === CtasEnum.VIEW && ( )} } > Name
); } } SqlEditor.defaultProps = defaultProps; SqlEditor.propTypes = propTypes; function mapStateToProps(state, props) { const { sqlLab } = state; const queryEditor = sqlLab.queryEditors.find( editor => editor.id === props.queryEditorId, ); return { sqlLab, ...props, queryEditor }; } function mapDispatchToProps(dispatch) { return bindActionCreators( { addQueryEditor, estimateQueryCost, persistEditorHeight, postStopQuery, queryEditorSetAutorun, queryEditorSetQueryLimit, queryEditorSetSql, queryEditorSetTemplateParams, runQuery, saveQuery, scheduleQuery, setActiveSouthPaneTab, updateSavedQuery, validateQuery, }, dispatch, ); } export default connect(mapStateToProps, mapDispatchToProps)(SqlEditor);