diff --git a/superset/assets/spec/javascripts/sqllab/LimitControl_spec.jsx b/superset/assets/spec/javascripts/sqllab/LimitControl_spec.jsx new file mode 100644 index 0000000000000..7869eae227e6d --- /dev/null +++ b/superset/assets/spec/javascripts/sqllab/LimitControl_spec.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Label } from 'react-bootstrap'; +import LimitControl from '../../../src/SqlLab/components/LimitControl'; +import ControlHeader from '../../../src/explore/components/ControlHeader'; + +describe('LimitControl', () => { + const defaultProps = { + value: 0, + defaultQueryLimit: 1000, + maxRow: 100000, + onChange: () => {}, + }; + let wrapper; + const factory = o => ; + beforeEach(() => { + wrapper = shallow(factory(defaultProps)); + }); + it('is a valid element', () => { + expect(React.isValidElement()).toEqual(true); + }); + it('renders a Label', () => { + expect(wrapper.find(Label)).toHaveLength(1); + }); + it('loads the correct state', () => { + const value = 100; + wrapper = shallow(factory({ ...defaultProps, value })); + expect(wrapper.state().textValue).toEqual(value.toString()); + wrapper.find(Label).first().simulate('click'); + expect(wrapper.state().showOverlay).toBe(true); + expect(wrapper.find(ControlHeader).props().validationErrors).toHaveLength(0); + }); + it('handles invalid value', () => { + wrapper.find(Label).first().simulate('click'); + wrapper.setState({ textValue: 'invalid' }); + expect(wrapper.find(ControlHeader).props().validationErrors).toHaveLength(1); + }); + it('handles negative value', () => { + wrapper.find(Label).first().simulate('click'); + wrapper.setState({ textValue: '-1' }); + expect(wrapper.find(ControlHeader).props().validationErrors).toHaveLength(1); + }); + it('handles value above max row', () => { + wrapper.find(Label).first().simulate('click'); + wrapper.setState({ textValue: (defaultProps.maxRow + 1).toString() }); + expect(wrapper.find(ControlHeader).props().validationErrors).toHaveLength(1); + }); + it('opens and closes', () => { + wrapper.find(Label).first().simulate('click'); + expect(wrapper.state().showOverlay).toBe(true); + wrapper.find('.ok').first().simulate('click'); + expect(wrapper.state().showOverlay).toBe(false); + }); + it('resets and closes', () => { + const value = 100; + wrapper = shallow(factory({ ...defaultProps, value })); + wrapper.find(Label).first().simulate('click'); + expect(wrapper.state().textValue).toEqual(value.toString()); + wrapper.find('.reset').simulate('click'); + expect(wrapper.state().textValue).toEqual(defaultProps.defaultQueryLimit.toString()); + }); +}); diff --git a/superset/assets/spec/javascripts/sqllab/SqlEditor_spec.jsx b/superset/assets/spec/javascripts/sqllab/SqlEditor_spec.jsx index 822192dd2f87d..95db9b05dea06 100644 --- a/superset/assets/spec/javascripts/sqllab/SqlEditor_spec.jsx +++ b/superset/assets/spec/javascripts/sqllab/SqlEditor_spec.jsx @@ -1,7 +1,8 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { initialState, queries, table } from './fixtures'; +import { defaultQueryEditor, initialState, queries, table } from './fixtures'; +import LimitControl from '../../../src/SqlLab/components/LimitControl'; import SqlEditor from '../../../src/SqlLab/components/SqlEditor'; import SqlEditorLeftBar from '../../../src/SqlLab/components/SqlEditorLeftBar'; @@ -16,6 +17,8 @@ describe('SqlEditor', () => { getHeight: () => ('100px'), editorQueries: [], dataPreviewQueries: [], + defaultQueryLimit: 1000, + maxRow: 100000, }; it('is valid', () => { expect( @@ -26,4 +29,18 @@ describe('SqlEditor', () => { const wrapper = shallow(); expect(wrapper.find(SqlEditorLeftBar)).toHaveLength(1); }); + it('render a LimitControl with default limit', () => { + const defaultQueryLimit = 101; + const updatedProps = { ...mockedProps, defaultQueryLimit }; + const wrapper = shallow(); + expect(wrapper.find(LimitControl)).toHaveLength(1); + expect(wrapper.find(LimitControl).props().value).toEqual(defaultQueryLimit); + }); + it('render a LimitControl with existing limit', () => { + const queryEditor = { ...defaultQueryEditor, queryLimit: 101 }; + const updatedProps = { ...mockedProps, queryEditor }; + const wrapper = shallow(); + expect(wrapper.find(LimitControl)).toHaveLength(1); + expect(wrapper.find(LimitControl).props().value).toEqual(queryEditor.queryLimit); + }); }); diff --git a/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx b/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx index 33d1e476af9c9..111aba9499fed 100644 --- a/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx +++ b/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx @@ -52,6 +52,8 @@ describe('TabbedSqlEditors', () => { editorHeight: '', getHeight: () => ('100px'), database: {}, + defaultQueryLimit: 1000, + maxRow: 100000, }; const getWrapper = () => ( shallow(, { diff --git a/superset/assets/spec/javascripts/sqllab/fixtures.js b/superset/assets/spec/javascripts/sqllab/fixtures.js index 02a6c127f49aa..99ba6a8e6814d 100644 --- a/superset/assets/spec/javascripts/sqllab/fixtures.js +++ b/superset/assets/spec/javascripts/sqllab/fixtures.js @@ -367,6 +367,12 @@ export const initialState = { activeSouthPaneTab: 'Results', }, messageToasts: [], + common: { + conf: { + DEFAULT_SQLLAB_LIMIT: 1000, + SQL_MAX_ROW: 100000, + }, + }, }; export const query = { diff --git a/superset/assets/spec/javascripts/sqllab/reducers/sqlLab_spec.js b/superset/assets/spec/javascripts/sqllab/reducers/sqlLab_spec.js index 964daa8fbd927..ba189ca0488ea 100644 --- a/superset/assets/spec/javascripts/sqllab/reducers/sqlLab_spec.js +++ b/superset/assets/spec/javascripts/sqllab/reducers/sqlLab_spec.js @@ -81,6 +81,11 @@ describe('sqlLabReducer', () => { newState = sqlLabReducer(newState, actions.queryEditorSetSql(qe, sql)); expect(newState.queryEditors[1].sql).toBe(sql); }); + it('should not fail while setting queryLimit', () => { + const queryLimit = 101; + newState = sqlLabReducer(newState, actions.queryEditorSetQueryLimit(qe, queryLimit)); + expect(newState.queryEditors[1].queryLimit).toEqual(queryLimit); + }); it('should set selectedText', () => { const selectedText = 'TEST'; expect(newState.queryEditors[0].selectedText).toBeNull(); diff --git a/superset/assets/src/SqlLab/actions/sqlLab.js b/superset/assets/src/SqlLab/actions/sqlLab.js index 17e022b8c723c..084aaea80fa4c 100644 --- a/superset/assets/src/SqlLab/actions/sqlLab.js +++ b/superset/assets/src/SqlLab/actions/sqlLab.js @@ -27,6 +27,7 @@ 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_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_PERSIST_HEIGHT = 'QUERY_EDITOR_PERSIST_HEIGHT'; @@ -141,6 +142,7 @@ export function runQuery(query) { tmp_table_name: query.tempTableName, select_as_cta: query.ctas, templateParams: query.templateParams, + queryLimit: query.queryLimit, }; return SupersetClient.post({ @@ -230,6 +232,10 @@ export function queryEditorSetSql(queryEditor, sql) { return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql }; } +export function queryEditorSetQueryLimit(queryEditor, queryLimit) { + return { type: QUERY_EDITOR_SET_QUERY_LIMIT, queryEditor, queryLimit }; +} + export function queryEditorSetTemplateParams(queryEditor, templateParams) { return { type: QUERY_EDITOR_SET_TEMPLATE_PARAMS, queryEditor, templateParams }; } @@ -325,6 +331,7 @@ export function reFetchQueryResults(query) { tab: '', runAsync: false, ctas: false, + queryLimit: query.queryLimit, }; dispatch(runQuery(newQuery)); dispatch(changeDataPreviewId(query.id, newQuery)); diff --git a/superset/assets/src/SqlLab/components/LimitControl.jsx b/superset/assets/src/SqlLab/components/LimitControl.jsx new file mode 100644 index 0000000000000..cdb1f5750de5b --- /dev/null +++ b/superset/assets/src/SqlLab/components/LimitControl.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Label, + FormGroup, + FormControl, + Overlay, + Popover, +} from 'react-bootstrap'; +import { t } from '@superset-ui/translation'; + +import ControlHeader from '../../explore/components/ControlHeader'; + +const propTypes = { + value: PropTypes.number, + defaultQueryLimit: PropTypes.number.isRequired, + maxRow: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default class LimitControl extends React.PureComponent { + constructor(props) { + super(props); + const { value, defaultQueryLimit } = props; + this.state = { + textValue: value.toString() || defaultQueryLimit.toString(), + showOverlay: false, + }; + this.handleHide = this.handleHide.bind(this); + this.handleToggle = this.handleToggle.bind(this); + this.submitAndClose = this.submitAndClose.bind(this); + } + + setValueAndClose(val) { + this.setState({ textValue: val }, this.submitAndClose); + } + + submitAndClose() { + const value = parseInt(this.state.textValue, 10) || this.props.defaultQueryLimit; + this.props.onChange(value); + this.setState({ showOverlay: false }); + } + + isValidLimit(limit) { + const value = parseInt(limit, 10); + return !(Number.isNaN(value) || value <= 0 || (this.props.maxRow && value > this.props.maxRow)); + } + + handleToggle() { + this.setState({ showOverlay: !this.state.showOverlay }); + } + + handleHide() { + this.setState({ showOverlay: false }); + } + + renderPopover() { + const textValue = this.state.textValue; + const isValid = this.isValidLimit(textValue); + const errorMsg = 'Row limit must be positive integer' + + (this.props.maxRow ? ` and not greater than ${this.props.maxRow}` : ''); + return ( + +
+ + + this.setState({ textValue: e.target.value })} + /> + +
+ + +
+
+
+ ); + } + + render() { + return ( +
+ + + {this.renderPopover()} + +
+ ); + } +} + +LimitControl.propTypes = propTypes; diff --git a/superset/assets/src/SqlLab/components/SqlEditor.jsx b/superset/assets/src/SqlLab/components/SqlEditor.jsx index e739efa518f71..7a387707c0ba4 100644 --- a/superset/assets/src/SqlLab/components/SqlEditor.jsx +++ b/superset/assets/src/SqlLab/components/SqlEditor.jsx @@ -17,6 +17,7 @@ import SplitPane from 'react-split-pane'; import { t } from '@superset-ui/translation'; import Button from '../../components/Button'; +import LimitControl from './LimitControl'; import TemplateParamsEditor from './TemplateParamsEditor'; import SouthPane from './SouthPane'; import SaveQuery from './SaveQuery'; @@ -38,6 +39,8 @@ const propTypes = { dataPreviewQueries: PropTypes.array.isRequired, queryEditor: PropTypes.object.isRequired, hideLeftBar: PropTypes.bool, + defaultQueryLimit: PropTypes.number.isRequired, + maxRow: PropTypes.number.isRequired, }; const defaultProps = { @@ -130,6 +133,9 @@ class SqlEditor extends React.PureComponent { setQueryEditorSql(sql) { this.props.actions.queryEditorSetSql(this.props.queryEditor, sql); } + setQueryLimit(queryLimit) { + this.props.actions.queryEditorSetQueryLimit(this.props.queryEditor, queryLimit); + } runQuery() { this.startQuery(!(this.props.database || {}).allow_run_sync); } @@ -143,6 +149,7 @@ class SqlEditor extends React.PureComponent { schema: qe.schema, tempTableName: ctas ? this.state.ctas : '', templateParams: qe.templateParams, + queryLimit: qe.queryLimit, runAsync, ctas, }; @@ -236,7 +243,18 @@ class SqlEditor extends React.PureComponent { - {ctasControls} + + {ctasControls} + + + + )} @@ -243,7 +247,7 @@ class TabbedSqlEditors extends React.PureComponent { TabbedSqlEditors.propTypes = propTypes; TabbedSqlEditors.defaultProps = defaultProps; -function mapStateToProps({ sqlLab }) { +function mapStateToProps({ sqlLab, common }) { return { databases: sqlLab.databases, queryEditors: sqlLab.queryEditors, @@ -252,6 +256,8 @@ function mapStateToProps({ sqlLab }) { tables: sqlLab.tables, defaultDbId: sqlLab.defaultDbId, offline: sqlLab.offline, + defaultQueryLimit: common.conf.DEFAULT_SQLLAB_LIMIT, + maxRow: common.conf.SQL_MAX_ROW, }; } function mapDispatchToProps(dispatch) { diff --git a/superset/assets/src/SqlLab/reducers/getInitialState.js b/superset/assets/src/SqlLab/reducers/getInitialState.js index 14bd677d39d8a..3351a22a5c03c 100644 --- a/superset/assets/src/SqlLab/reducers/getInitialState.js +++ b/superset/assets/src/SqlLab/reducers/getInitialState.js @@ -11,6 +11,7 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) { latestQueryId: null, autorun: false, dbId: defaultDbId, + queryLimit: restBootstrapData.common.conf.DEFAULT_SQLLAB_LIMIT, }; return { diff --git a/superset/assets/src/SqlLab/reducers/sqlLab.js b/superset/assets/src/SqlLab/reducers/sqlLab.js index b8a27a97d4153..ff881c327a3ee 100644 --- a/superset/assets/src/SqlLab/reducers/sqlLab.js +++ b/superset/assets/src/SqlLab/reducers/sqlLab.js @@ -32,6 +32,8 @@ export default function sqlLabReducer(state = {}, action) { schema: action.query.schema ? action.query.schema : null, autorun: true, sql: action.query.sql, + queryLimit: action.query.queryLimit, + maxRow: action.query.maxRow, }; return sqlLabReducer(state, actions.addQueryEditor(qe)); @@ -204,6 +206,9 @@ export default function sqlLabReducer(state = {}, action) { [actions.QUERY_EDITOR_SET_SQL]() { return alterInArr(state, 'queryEditors', action.queryEditor, { sql: action.sql }); }, + [actions.QUERY_EDITOR_SET_QUERY_LIMIT]() { + return alterInArr(state, 'queryEditors', action.queryEditor, { queryLimit: action.queryLimit }); + }, [actions.QUERY_EDITOR_SET_TEMPLATE_PARAMS]() { return alterInArr(state, 'queryEditors', action.queryEditor, { templateParams: action.templateParams, diff --git a/superset/config.py b/superset/config.py index c94373969f52e..a34e5d96f436b 100644 --- a/superset/config.py +++ b/superset/config.py @@ -282,8 +282,8 @@ # in the results backend. This also becomes the limit when exporting CSVs SQL_MAX_ROW = 100000 -# Limit to be returned to the frontend. -DISPLAY_MAX_ROW = 1000 +# Default row limit for SQL Lab queries +DEFAULT_SQLLAB_LIMIT = 1000 # Maximum number of tables/views displayed in the dropdown window in SQL Lab. MAX_TABLE_NAMES = 3000 diff --git a/superset/sql_lab.py b/superset/sql_lab.py index d211f025c5b73..1d4171b175780 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -150,10 +150,11 @@ def handle_error(msg): query.user_id, start_dttm.strftime('%Y_%m_%d_%H_%M_%S')) executed_sql = superset_query.as_create_table(query.tmp_table_name) query.select_as_cta_used = True - if (superset_query.is_select() and SQL_MAX_ROWS and - (not query.limit or query.limit > SQL_MAX_ROWS)): - query.limit = SQL_MAX_ROWS - executed_sql = database.apply_limit_to_sql(executed_sql, query.limit) + if superset_query.is_select(): + if SQL_MAX_ROWS and (not query.limit or query.limit > SQL_MAX_ROWS): + query.limit = SQL_MAX_ROWS + if query.limit: + executed_sql = database.apply_limit_to_sql(executed_sql, query.limit) # Hook to allow environment-specific mutation (usually comments) to the SQL SQL_QUERY_MUTATOR = config.get('SQL_QUERY_MUTATOR') diff --git a/superset/views/base.py b/superset/views/base.py index 4cba8b23df888..4b4926c959cab 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -24,6 +24,8 @@ 'SUPERSET_WEBSERVER_TIMEOUT', 'SUPERSET_DASHBOARD_POSITION_DATA_LIMIT', 'ENABLE_JAVASCRIPT_CONTROLS', + 'DEFAULT_SQLLAB_LIMIT', + 'SQL_MAX_ROW', ) diff --git a/superset/views/core.py b/superset/views/core.py index a16bceb4c289a..1f11e704b4e7b 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -2456,7 +2456,7 @@ def results(self, key): '{}'.format(rejected_tables)), status=403) payload = utils.zlib_decompress_to_string(blob) - display_limit = app.config.get('DISPLAY_MAX_ROW', None) + display_limit = app.config.get('DEFAULT_SQLLAB_LIMIT', None) if display_limit: payload_json = json.loads(payload) payload_json['data'] = payload_json['data'][:display_limit] @@ -2495,6 +2495,12 @@ def sql_json(self): schema = request.form.get('schema') or None template_params = json.loads( request.form.get('templateParams') or '{}') + limit = int(request.form.get('queryLimit', 0)) + if limit < 0: + logging.warning( + 'Invalid limit of {} specified. Defaulting to max limit.'.format(limit)) + limit = 0 + limit = limit or app.config.get('SQL_MAX_ROW') session = db.session() mydb = session.query(models.Database).filter_by(id=database_id).first() @@ -2520,10 +2526,10 @@ def sql_json(self): ) client_id = request.form.get('client_id') or utils.shortid()[:10] - + limits = [mydb.db_engine_spec.get_limit_from_sql(sql), limit] query = Query( database_id=int(database_id), - limit=mydb.db_engine_spec.get_limit_from_sql(sql), + limit=min(lim for lim in limits if lim is not None), sql=sql, schema=schema, select_as_cta=request.form.get('select_as_cta') == 'true', diff --git a/tests/base_tests.py b/tests/base_tests.py index b9b4649a50dbe..7ff1036e321d5 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -154,7 +154,8 @@ def revoke_public_access_to_table(self, table): perm.view_menu and table.perm in perm.view_menu.name): security_manager.del_permission_role(public_role, perm) - def run_sql(self, sql, client_id=None, user_name=None, raise_on_error=False): + def run_sql(self, sql, client_id=None, user_name=None, raise_on_error=False, + query_limit=None): if user_name: self.logout() self.login(username=(user_name if user_name else 'admin')) @@ -163,7 +164,7 @@ def run_sql(self, sql, client_id=None, user_name=None, raise_on_error=False): '/superset/sql_json/', raise_on_error=False, data=dict(database_id=dbid, sql=sql, select_as_create_as=False, - client_id=client_id), + client_id=client_id, queryLimit=query_limit), ) if raise_on_error and 'error' in resp: raise Exception('run_sql failed') diff --git a/tests/sqllab_tests.py b/tests/sqllab_tests.py index ca2063cf12d3c..95e3fbc144fd0 100644 --- a/tests/sqllab_tests.py +++ b/tests/sqllab_tests.py @@ -260,6 +260,29 @@ def test_sqllab_viz(self): resp = self.get_json_resp('/superset/sqllab_viz/', data=data) self.assertIn('table_id', resp) + def test_sql_limit(self): + self.login('admin') + test_limit = 1 + data = self.run_sql( + 'SELECT * FROM ab_user', + client_id='sql_limit_1') + self.assertGreater(len(data['data']), test_limit) + data = self.run_sql( + 'SELECT * FROM ab_user', + client_id='sql_limit_2', + query_limit=test_limit) + self.assertEquals(len(data['data']), test_limit) + data = self.run_sql( + 'SELECT * FROM ab_user LIMIT {}'.format(test_limit), + client_id='sql_limit_3', + query_limit=test_limit + 1) + self.assertEquals(len(data['data']), test_limit) + data = self.run_sql( + 'SELECT * FROM ab_user LIMIT {}'.format(test_limit + 1), + client_id='sql_limit_4', + query_limit=test_limit) + self.assertEquals(len(data['data']), test_limit) + if __name__ == '__main__': unittest.main()