diff --git a/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx b/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx index eca063c7f4d..2ba62687c81 100644 --- a/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx @@ -33,7 +33,10 @@ import ConnectedSouthPane from 'src/SqlLab/components/SouthPane'; import SqlEditor from 'src/SqlLab/components/SqlEditor'; import SqlEditorLeftBar from 'src/SqlLab/components/SqlEditorLeftBar'; import { Dropdown } from 'src/common/components'; -import { queryEditorSetSelectedText } from 'src/SqlLab/actions/sqlLab'; +import { + queryEditorSetFunctionNames, + queryEditorSetSelectedText, +} from 'src/SqlLab/actions/sqlLab'; import { initialState, queries, table } from './fixtures'; @@ -45,7 +48,7 @@ const store = mockStore(initialState); describe('SqlEditor', () => { const mockedProps = { - actions: { queryEditorSetSelectedText }, + actions: { queryEditorSetFunctionNames, queryEditorSetSelectedText }, database: {}, queryEditorId: initialState.sqlLab.queryEditors[0].id, latestQuery: queries[0], diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index 647cfad3d8b..090d69ca3ba 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -57,6 +57,8 @@ 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_SET_FUNCTION_NAMES = + 'QUERY_EDITOR_SET_FUNCTION_NAMES'; export const QUERY_EDITOR_PERSIST_HEIGHT = 'QUERY_EDITOR_PERSIST_HEIGHT'; export const MIGRATE_QUERY_EDITOR = 'MIGRATE_QUERY_EDITOR'; export const MIGRATE_TAB_HISTORY = 'MIGRATE_TAB_HISTORY'; @@ -1300,3 +1302,23 @@ export function createCtasDatasource(vizOptions) { }); }; } + +export function queryEditorSetFunctionNames(queryEditor, dbId) { + return function (dispatch) { + return SupersetClient.get({ + endpoint: encodeURI(`/api/v1/database/${dbId}/function_names/`), + }) + .then(({ json }) => + dispatch({ + type: QUERY_EDITOR_SET_FUNCTION_NAMES, + queryEditor, + functionNames: json.function_names, + }), + ) + .catch(() => + dispatch( + addDangerToast(t('An error occurred while fetching function names.')), + ), + ); + }; +} diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx index 95c6eb8cfcf..3c33f4c3624 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx @@ -41,6 +41,7 @@ type HotKey = { interface Props { actions: { queryEditorSetSelectedText: (edit: any, text: null | string) => void; + queryEditorSetFunctionNames: (queryEditor: object, dbId: number) => void; addTable: (queryEditor: any, value: any, schema: any) => void; }; autocomplete: boolean; @@ -85,6 +86,10 @@ class AceEditorWrapper extends React.PureComponent { componentDidMount() { // Making sure no text is selected from previous mount this.props.actions.queryEditorSetSelectedText(this.props.queryEditor, null); + this.props.actions.queryEditorSetFunctionNames( + this.props.queryEditor, + this.props.queryEditor.dbId, + ); this.setAutoCompleter(this.props); } diff --git a/superset-frontend/src/SqlLab/components/SqlEditor.jsx b/superset-frontend/src/SqlLab/components/SqlEditor.jsx index ee89a0f7070..3af2b1fa15c 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor.jsx @@ -474,9 +474,7 @@ class SqlEditor extends React.PureComponent { sql={this.props.queryEditor.sql} schemas={this.props.queryEditor.schemaOptions} tables={this.props.queryEditor.tableOptions} - functionNames={ - this.props.database ? this.props.database.function_names : [] - } + functionNames={this.props.queryEditor.functionNames} extendedTables={this.props.tables} height={`${aceEditorHeight}px`} hotkeys={hotkeys} diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx index 550f623412a..7136ab0370e 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx @@ -80,6 +80,10 @@ export default class SqlEditorLeftBar extends React.PureComponent { onDbChange(db) { this.props.actions.queryEditorSetDb(this.props.queryEditor, db.id); + this.props.actions.queryEditorSetFunctionNames( + this.props.queryEditor, + db.id, + ); } onTableChange(tableName, schemaName) { diff --git a/superset-frontend/src/SqlLab/reducers/getInitialState.js b/superset-frontend/src/SqlLab/reducers/getInitialState.js index 60cd420da92..df55748eabc 100644 --- a/superset-frontend/src/SqlLab/reducers/getInitialState.js +++ b/superset-frontend/src/SqlLab/reducers/getInitialState.js @@ -48,6 +48,7 @@ export default function getInitialState({ autorun: false, templateParams: null, dbId: defaultDbId, + functionNames: [], queryLimit: common.conf.DEFAULT_SQLLAB_LIMIT, validationResult: { id: null, @@ -80,6 +81,7 @@ export default function getInitialState({ autorun: activeTab.autorun, templateParams: activeTab.template_params, dbId: activeTab.database_id, + functionNames: [], schema: activeTab.schema, queryLimit: activeTab.query_limit, validationResult: { diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.js b/superset-frontend/src/SqlLab/reducers/sqlLab.js index 8a94bcb3ecc..a5930d2b348 100644 --- a/superset-frontend/src/SqlLab/reducers/sqlLab.js +++ b/superset-frontend/src/SqlLab/reducers/sqlLab.js @@ -434,6 +434,11 @@ export default function sqlLabReducer(state = {}, action) { dbId: action.dbId, }); }, + [actions.QUERY_EDITOR_SET_FUNCTION_NAMES]() { + return alterInArr(state, 'queryEditors', action.queryEditor, { + functionNames: action.functionNames, + }); + }, [actions.QUERY_EDITOR_SET_SCHEMA]() { return alterInArr(state, 'queryEditors', action.queryEditor, { schema: action.schema, diff --git a/superset/databases/api.py b/superset/databases/api.py index e8273eb1e76..ba665c1cb41 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -53,6 +53,7 @@ from superset.databases.decorators import check_datasource_access from superset.databases.filters import DatabaseFilter from superset.databases.schemas import ( database_schemas_query_schema, + DatabaseFunctionNamesResponse, DatabasePostSchema, DatabasePutSchema, DatabaseRelatedObjectsResponse, @@ -83,6 +84,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi): "schemas", "test_connection", "related_objects", + "function_names", } resource_name = "database" class_permission_name = "Database" @@ -126,7 +128,6 @@ class DatabaseRestApi(BaseSupersetModelRestApi): "explore_database_id", "expose_in_sqllab", "force_ctas_schema", - "function_names", "id", ] add_columns = [ @@ -170,6 +171,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi): } openapi_spec_tag = "Database" openapi_spec_component_schemas = ( + DatabaseFunctionNamesResponse, DatabaseRelatedObjectsResponse, DatabaseTestConnectionSchema, TableMetadataResponseSchema, @@ -642,8 +644,8 @@ class DatabaseRestApi(BaseSupersetModelRestApi): 500: $ref: '#/components/responses/500' """ - dataset = DatabaseDAO.find_by_id(pk) - if not dataset: + database = DatabaseDAO.find_by_id(pk) + if not database: return self.response_404() data = DatabaseDAO.get_related_objects(pk) charts = [ @@ -799,3 +801,43 @@ class DatabaseRestApi(BaseSupersetModelRestApi): except DatabaseImportError as exc: logger.exception("Import database failed") return self.response_500(message=str(exc)) + + @expose("//function_names/", methods=["GET"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" + f".function_names", + log_to_statsd=False, + ) + def function_names(self, pk: int) -> Response: + """Get function names supported by a database + --- + get: + description: + Get function names supported by a database + parameters: + - in: path + name: pk + schema: + type: integer + responses: + 200: + 200: + description: Query result + content: + application/json: + schema: + $ref: "#/components/schemas/DatabaseFunctionNamesResponse" + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + database = DatabaseDAO.find_by_id(pk) + if not database: + return self.response_404() + return self.response(200, function_names=database.function_names,) diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py index 2c705f35b1f..0fbcf1dcb6a 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -413,11 +413,15 @@ class DatabaseRelatedObjectsResponse(Schema): dashboards = fields.Nested(DatabaseRelatedDashboards) +class DatabaseFunctionNamesResponse(Schema): + function_names = fields.List(fields.String()) + + class ImportV1DatabaseExtraSchema(Schema): metadata_params = fields.Dict(keys=fields.Str(), values=fields.Raw()) engine_params = fields.Dict(keys=fields.Str(), values=fields.Raw()) metadata_cache_timeout = fields.Dict(keys=fields.Str(), values=fields.Integer()) - schemas_allowed_for_csv_upload = fields.List(fields.String) + schemas_allowed_for_csv_upload = fields.List(fields.String()) cost_estimate_enabled = fields.Boolean() diff --git a/tests/databases/api_tests.py b/tests/databases/api_tests.py index 8d603ccd9b3..a72c7eb12a9 100644 --- a/tests/databases/api_tests.py +++ b/tests/databases/api_tests.py @@ -137,7 +137,6 @@ class TestDatabaseApi(SupersetTestCase): "explore_database_id", "expose_in_sqllab", "force_ctas_schema", - "function_names", "id", ] self.assertGreater(response["count"], 0) @@ -589,7 +588,8 @@ class TestDatabaseApi(SupersetTestCase): assert rv.status_code == 200 assert "can_read" in data["permissions"] assert "can_write" in data["permissions"] - assert len(data["permissions"]) == 2 + assert "can_function_names" in data["permissions"] + assert len(data["permissions"]) == 3 def test_get_invalid_database_table_metadata(self): """ @@ -1125,3 +1125,20 @@ class TestDatabaseApi(SupersetTestCase): db.session.delete(database) db.session.commit() + + @mock.patch("superset.db_engine_specs.base.BaseEngineSpec.get_function_names",) + def test_function_names(self, mock_get_function_names): + example_db = get_example_database() + if example_db.backend in {"hive", "presto"}: + return + + mock_get_function_names.return_value = ["AVG", "MAX", "SUM"] + + self.login(username="admin") + uri = "api/v1/database/1/function_names/" + + rv = self.client.get(uri) + response = json.loads(rv.data.decode("utf-8")) + + assert rv.status_code == 200 + assert response == {"function_names": ["AVG", "MAX", "SUM"]}