From c8c94825ce8ec0d276c7e8c8d5aacbd4ca699b6f Mon Sep 17 00:00:00 2001 From: Zef Lin Date: Mon, 18 Sep 2023 11:30:52 -0700 Subject: [PATCH] fix(chart): Supporting custom SQL as temporal x-axis column with filter (#25126) Co-authored-by: Kamil Gabryjelski --- .../explore/actions/exploreActions.test.js | 13 ++-- .../src/explore/reducers/exploreReducer.js | 18 ++--- superset/common/query_context_factory.py | 7 +- superset/connectors/sqla/models.py | 2 + .../charts/data/api_tests.py | 66 +++++++++++++++++++ 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/superset-frontend/src/explore/actions/exploreActions.test.js b/superset-frontend/src/explore/actions/exploreActions.test.js index cdf76bdcc6a6f..9dd53756800d1 100644 --- a/superset-frontend/src/explore/actions/exploreActions.test.js +++ b/superset-frontend/src/explore/actions/exploreActions.test.js @@ -22,20 +22,19 @@ import exploreReducer from 'src/explore/reducers/exploreReducer'; import * as actions from 'src/explore/actions/exploreActions'; describe('reducers', () => { - it('sets correct control value given an arbitrary key and value', () => { + it('Does not set a control value if control does not exist', () => { const newState = exploreReducer( defaultState, actions.setControlValue('NEW_FIELD', 'x', []), ); - expect(newState.controls.NEW_FIELD.value).toBe('x'); - expect(newState.form_data.NEW_FIELD).toBe('x'); + expect(newState.controls.NEW_FIELD).toBeUndefined(); }); - it('setControlValue works as expected with a checkbox', () => { + it('setControlValue works as expected with a Select control', () => { const newState = exploreReducer( defaultState, - actions.setControlValue('show_legend', true, []), + actions.setControlValue('y_axis_format', '$,.2f', []), ); - expect(newState.controls.show_legend.value).toBe(true); - expect(newState.form_data.show_legend).toBe(true); + expect(newState.controls.y_axis_format.value).toBe('$,.2f'); + expect(newState.form_data.y_axis_format).toBe('$,.2f'); }); }); diff --git a/superset-frontend/src/explore/reducers/exploreReducer.js b/superset-frontend/src/explore/reducers/exploreReducer.js index ac451ade3da16..d5565a0dad5eb 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.js +++ b/superset-frontend/src/explore/reducers/exploreReducer.js @@ -112,7 +112,7 @@ export default function exploreReducer(state = {}, action) { const vizType = new_form_data.viz_type; // if the controlName is metrics, and the metric column name is updated, - // need to update column config as well to keep the previou config. + // need to update column config as well to keep the previous config. if (controlName === 'metrics' && old_metrics_data && new_column_config) { value.forEach((item, index) => { if ( @@ -129,11 +129,11 @@ export default function exploreReducer(state = {}, action) { } // Use the processed control config (with overrides and everything) - // if `controlName` does not existing in current controls, + // if `controlName` does not exist in current controls, const controlConfig = state.controls[action.controlName] || getControlConfig(action.controlName, vizType) || - {}; + null; // will call validators again const control = { @@ -149,7 +149,7 @@ export default function exploreReducer(state = {}, action) { ...state, controls: { ...state.controls, - [controlName]: control, + ...(controlConfig && { [controlName]: control }), ...(controlName === 'metrics' && { column_config }), }, }; @@ -196,10 +196,12 @@ export default function exploreReducer(state = {}, action) { triggerRender: control.renderTrigger && !hasErrors, controls: { ...currentControlsState, - [action.controlName]: { - ...control, - validationErrors: errors, - }, + ...(controlConfig && { + [action.controlName]: { + ...control, + validationErrors: errors, + }, + }), ...rerenderedControls, }, }; diff --git a/superset/common/query_context_factory.py b/superset/common/query_context_factory.py index f2c8bcfefcccc..d6510ccd9a434 100644 --- a/superset/common/query_context_factory.py +++ b/superset/common/query_context_factory.py @@ -26,7 +26,7 @@ from superset.daos.chart import ChartDAO from superset.daos.datasource import DatasourceDAO from superset.models.slice import Slice -from superset.utils.core import DatasourceDict, DatasourceType +from superset.utils.core import DatasourceDict, DatasourceType, is_adhoc_column if TYPE_CHECKING: from superset.connectors.base.models import BaseDatasource @@ -128,6 +128,8 @@ def _apply_granularity( if granularity := query_object.granularity: filter_to_remove = None + if is_adhoc_column(x_axis): # type: ignore + x_axis = x_axis.get("sqlExpression") if x_axis and x_axis in temporal_columns: filter_to_remove = x_axis x_axis_column = next( @@ -175,6 +177,9 @@ def _apply_granularity( # another temporal filter. A new filter based on the value of # the granularity will be added later in the code. # In practice, this is replacing the previous default temporal filter. + if is_adhoc_column(filter_to_remove): # type: ignore + filter_to_remove = filter_to_remove.get("sqlExpression") + if filter_to_remove: query_object.filter = [ filter diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index b680b8df864e5..467ff331e412d 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -1011,6 +1011,8 @@ def adhoc_column_to_sqla( # pylint: disable=too-many-locals qry = sa.select([sqla_column]).limit(1).select_from(tbl) sql = self.database.compile_sqla_query(qry) col_desc = get_columns_description(self.database, self.schema, sql) + if not col_desc: + raise SupersetGenericDBErrorException("Column not found") is_dttm = col_desc[0]["is_dttm"] # type: ignore except SupersetGenericDBErrorException as ex: raise ColumnNotFoundException(message=str(ex)) from ex diff --git a/tests/integration_tests/charts/data/api_tests.py b/tests/integration_tests/charts/data/api_tests.py index dc82026986245..ab91cce55e825 100644 --- a/tests/integration_tests/charts/data/api_tests.py +++ b/tests/integration_tests/charts/data/api_tests.py @@ -51,6 +51,7 @@ from superset.superset_typing import AdhocColumn from superset.utils.core import ( AnnotationType, + backend, get_example_default_schema, AdhocMetricExpressionType, ExtraFiltersReasonType, @@ -943,6 +944,71 @@ def test_chart_data_get(self): assert data["result"][0]["status"] == "success" assert data["result"][0]["rowcount"] == 2 + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + def test_chart_data_get_with_x_axis_using_custom_sql(self): + """ + Chart data API: Test GET endpoint + """ + chart = db.session.query(Slice).filter_by(slice_name="Genders").one() + chart.query_context = json.dumps( + { + "datasource": {"id": chart.table.id, "type": "table"}, + "force": False, + "queries": [ + { + "time_range": "1900-01-01T00:00:00 : 2000-01-01T00:00:00", + "granularity": "ds", + "filters": [ + {"col": "ds", "op": "TEMPORAL_RANGE", "val": "No filter"} + ], + "extras": { + "having": "", + "where": "", + }, + "applied_time_extras": {}, + "columns": [ + { + "columnType": "BASE_AXIS", + "datasourceWarning": False, + "expressionType": "SQL", + "label": "My column", + "sqlExpression": "ds", + "timeGrain": "P1W", + } + ], + "metrics": ["sum__num"], + "orderby": [["sum__num", False]], + "annotation_layers": [], + "row_limit": 50000, + "timeseries_limit": 0, + "order_desc": True, + "url_params": {}, + "custom_params": {}, + "custom_form_data": {}, + } + ], + "form_data": { + "x_axis": { + "datasourceWarning": False, + "expressionType": "SQL", + "label": "My column", + "sqlExpression": "ds", + } + }, + "result_format": "json", + "result_type": "full", + } + ) + rv = self.get_assert_metric(f"api/v1/chart/{chart.id}/data/", "get_data") + assert rv.mimetype == "application/json" + data = json.loads(rv.data.decode("utf-8")) + assert data["result"][0]["status"] == "success" + + if backend() == "presto": + assert data["result"][0]["rowcount"] == 41 + else: + assert data["result"][0]["rowcount"] == 40 + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") def test_chart_data_get_forced(self): """