From 3ed288d4ee96fd0a8a40dc1f7804de6a07c34447 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Mon, 16 Jan 2023 11:07:52 -0300 Subject: [PATCH] chore: Migrate /superset/stop_query/ to API v1 (#22624) --- docs/static/resources/openapi.json | 1258 +++++++++++------ .../src/SqlLab/actions/sqlLab.js | 6 +- .../src/SqlLab/actions/sqlLab.test.js | 11 +- superset/constants.py | 1 + superset/exceptions.py | 4 + superset/queries/api.py | 78 +- superset/queries/dao.py | 27 + superset/queries/schemas.py | 8 + superset/views/core.py | 1 + tests/integration_tests/queries/api_tests.py | 52 + tests/unit_tests/dao/queries_test.py | 165 ++- 11 files changed, 1177 insertions(+), 434 deletions(-) diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json index 62153bac51cb8..8279811b53dc1 100644 --- a/docs/static/resources/openapi.json +++ b/docs/static/resources/openapi.json @@ -257,7 +257,7 @@ "AnnotationLayerRestApi.get_list": { "properties": { "changed_by": { - "$ref": "#/components/schemas/AnnotationLayerRestApi.get_list.User1" + "$ref": "#/components/schemas/AnnotationLayerRestApi.get_list.User" }, "changed_on": { "format": "date-time", @@ -268,7 +268,7 @@ "readOnly": true }, "created_by": { - "$ref": "#/components/schemas/AnnotationLayerRestApi.get_list.User" + "$ref": "#/components/schemas/AnnotationLayerRestApi.get_list.User1" }, "created_on": { "format": "date-time", @@ -414,13 +414,13 @@ "AnnotationRestApi.get_list": { "properties": { "changed_by": { - "$ref": "#/components/schemas/AnnotationRestApi.get_list.User1" + "$ref": "#/components/schemas/AnnotationRestApi.get_list.User" }, "changed_on_delta_humanized": { "readOnly": true }, "created_by": { - "$ref": "#/components/schemas/AnnotationRestApi.get_list.User" + "$ref": "#/components/schemas/AnnotationRestApi.get_list.User1" }, "end_dttm": { "format": "date-time", @@ -547,6 +547,17 @@ }, "type": "object" }, + "AvailableDomainsSchema": { + "properties": { + "domains": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "CacheInvalidationRequestSchema": { "properties": { "datasource_uids": { @@ -822,17 +833,12 @@ }, "ChartDataExtras": { "properties": { - "druid_time_origin": { - "description": "Starting point for time grain counting on legacy Druid datasources. Used to change e.g. Monday/Sunday first-day-of-week.", - "nullable": true, - "type": "string" - }, "having": { "description": "HAVING clause to be added to aggregate queries using AND operator.", "type": "string" }, "having_druid": { - "description": "HAVING filters to be added to legacy Druid datasource queries.", + "description": "HAVING filters to be added to legacy Druid datasource queries. This field is deprecated", "items": { "$ref": "#/components/schemas/ChartDataFilter" }, @@ -920,18 +926,20 @@ "NOT IN", "REGEX", "IS TRUE", - "IS FALSE" + "IS FALSE", + "TEMPORAL_RANGE" ], "example": "IN", "type": "string" }, "val": { - "description": "The value or values to compare against. Can be a string, integer, decimal or list, depending on the operator.", + "description": "The value or values to compare against. Can be a string, integer, decimal, None or list, depending on the operator.", "example": [ "China", "France", "Japan" - ] + ], + "nullable": true } }, "required": [ @@ -1060,13 +1068,13 @@ "operation": { "description": "Post processing operation type", "enum": [ - "_flatten_column_after_pivot", "aggregate", "boxplot", "compare", "contribution", "cum", "diff", + "escape_separator", "flatten", "geodetic_parse", "geohash_decode", @@ -1077,7 +1085,8 @@ "resample", "rolling", "select", - "sort" + "sort", + "unescape_separator" ], "example": "aggregate", "type": "string" @@ -1174,11 +1183,18 @@ }, "ChartDataQueryContextSchema": { "properties": { + "custom_cache_timeout": { + "description": "Override the default cache timeout", + "format": "int32", + "nullable": true, + "type": "integer" + }, "datasource": { "$ref": "#/components/schemas/ChartDataDatasource" }, "force": { "description": "Should the queries be forced to load from the source. Default: `false`", + "nullable": true, "type": "boolean" }, "form_data": { @@ -1436,7 +1452,7 @@ "type": "string" }, "cache_timeout": { - "description": "Cache timeout in following order: custom timeout, datasource timeout, default config timeout.", + "description": "Cache timeout in following order: custom timeout, datasource timeout, cache default timeout, config default cache timeout.", "format": "int32", "nullable": true, "type": "integer" @@ -1557,6 +1573,9 @@ "nullable": true, "type": "string" }, + "changed_on_delta_humanized": { + "readOnly": true + }, "dashboards": { "$ref": "#/components/schemas/ChartDataRestApi.get.Dashboard" }, @@ -1564,6 +1583,10 @@ "nullable": true, "type": "string" }, + "id": { + "format": "int32", + "type": "integer" + }, "is_managed_externally": { "type": "boolean" }, @@ -1583,6 +1606,12 @@ "nullable": true, "type": "string" }, + "thumbnail_url": { + "readOnly": true + }, + "url": { + "readOnly": true + }, "viz_type": { "maxLength": 250, "nullable": true, @@ -1651,7 +1680,7 @@ "type": "string" }, "changed_by": { - "$ref": "#/components/schemas/ChartDataRestApi.get_list.User" + "$ref": "#/components/schemas/ChartDataRestApi.get_list.User1" }, "changed_by_name": { "readOnly": true @@ -1666,7 +1695,13 @@ "readOnly": true }, "created_by": { - "$ref": "#/components/schemas/ChartDataRestApi.get_list.User1" + "$ref": "#/components/schemas/ChartDataRestApi.get_list.User3" + }, + "created_on_delta_humanized": { + "readOnly": true + }, + "dashboards": { + "$ref": "#/components/schemas/ChartDataRestApi.get_list.Dashboard" }, "datasource_id": { "format": "int32", @@ -1707,10 +1742,10 @@ "type": "string" }, "last_saved_by": { - "$ref": "#/components/schemas/ChartDataRestApi.get_list.User2" + "$ref": "#/components/schemas/ChartDataRestApi.get_list.User" }, "owners": { - "$ref": "#/components/schemas/ChartDataRestApi.get_list.User1" + "$ref": "#/components/schemas/ChartDataRestApi.get_list.User2" }, "params": { "nullable": true, @@ -1738,6 +1773,20 @@ }, "type": "object" }, + "ChartDataRestApi.get_list.Dashboard": { + "properties": { + "dashboard_title": { + "maxLength": 500, + "nullable": true, + "type": "string" + }, + "id": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, "ChartDataRestApi.get_list.SqlaTable": { "properties": { "default_endpoint": { @@ -1760,6 +1809,10 @@ "maxLength": 64, "type": "string" }, + "id": { + "format": "int32", + "type": "integer" + }, "last_name": { "maxLength": 64, "type": "string" @@ -1777,23 +1830,14 @@ "maxLength": 64, "type": "string" }, - "id": { - "format": "int32", - "type": "integer" - }, "last_name": { "maxLength": 64, "type": "string" - }, - "username": { - "maxLength": 64, - "type": "string" } }, "required": [ "first_name", - "last_name", - "username" + "last_name" ], "type": "object" }, @@ -1810,11 +1854,16 @@ "last_name": { "maxLength": 64, "type": "string" + }, + "username": { + "maxLength": 64, + "type": "string" } }, "required": [ "first_name", - "last_name" + "last_name", + "username" ], "type": "object" }, @@ -2231,7 +2280,8 @@ "description": "Form data from the Explore controls used to form the chart's data query.", "type": "object" }, - "slice_id": { + "id": { + "description": "The id of the chart.", "format": "int32", "type": "integer" }, @@ -2315,6 +2365,9 @@ "nullable": true, "type": "string" }, + "changed_on_delta_humanized": { + "readOnly": true + }, "dashboards": { "$ref": "#/components/schemas/ChartRestApi.get.Dashboard" }, @@ -2322,6 +2375,10 @@ "nullable": true, "type": "string" }, + "id": { + "format": "int32", + "type": "integer" + }, "is_managed_externally": { "type": "boolean" }, @@ -2341,6 +2398,12 @@ "nullable": true, "type": "string" }, + "thumbnail_url": { + "readOnly": true + }, + "url": { + "readOnly": true + }, "viz_type": { "maxLength": 250, "nullable": true, @@ -2409,7 +2472,7 @@ "type": "string" }, "changed_by": { - "$ref": "#/components/schemas/ChartRestApi.get_list.User" + "$ref": "#/components/schemas/ChartRestApi.get_list.User1" }, "changed_by_name": { "readOnly": true @@ -2424,7 +2487,13 @@ "readOnly": true }, "created_by": { - "$ref": "#/components/schemas/ChartRestApi.get_list.User1" + "$ref": "#/components/schemas/ChartRestApi.get_list.User3" + }, + "created_on_delta_humanized": { + "readOnly": true + }, + "dashboards": { + "$ref": "#/components/schemas/ChartRestApi.get_list.Dashboard" }, "datasource_id": { "format": "int32", @@ -2465,10 +2534,10 @@ "type": "string" }, "last_saved_by": { - "$ref": "#/components/schemas/ChartRestApi.get_list.User2" + "$ref": "#/components/schemas/ChartRestApi.get_list.User" }, "owners": { - "$ref": "#/components/schemas/ChartRestApi.get_list.User1" + "$ref": "#/components/schemas/ChartRestApi.get_list.User2" }, "params": { "nullable": true, @@ -2496,6 +2565,20 @@ }, "type": "object" }, + "ChartRestApi.get_list.Dashboard": { + "properties": { + "dashboard_title": { + "maxLength": 500, + "nullable": true, + "type": "string" + }, + "id": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, "ChartRestApi.get_list.SqlaTable": { "properties": { "default_endpoint": { @@ -2518,6 +2601,10 @@ "maxLength": 64, "type": "string" }, + "id": { + "format": "int32", + "type": "integer" + }, "last_name": { "maxLength": 64, "type": "string" @@ -2535,23 +2622,14 @@ "maxLength": 64, "type": "string" }, - "id": { - "format": "int32", - "type": "integer" - }, "last_name": { "maxLength": 64, "type": "string" - }, - "username": { - "maxLength": 64, - "type": "string" } }, "required": [ "first_name", - "last_name", - "username" + "last_name" ], "type": "object" }, @@ -2568,11 +2646,16 @@ "last_name": { "maxLength": 64, "type": "string" + }, + "username": { + "maxLength": 64, + "type": "string" } }, "required": [ "first_name", - "last_name" + "last_name", + "username" ], "type": "object" }, @@ -2856,13 +2939,13 @@ "CssTemplateRestApi.get_list": { "properties": { "changed_by": { - "$ref": "#/components/schemas/CssTemplateRestApi.get_list.User1" + "$ref": "#/components/schemas/CssTemplateRestApi.get_list.User" }, "changed_on_delta_humanized": { "readOnly": true }, "created_by": { - "$ref": "#/components/schemas/CssTemplateRestApi.get_list.User" + "$ref": "#/components/schemas/CssTemplateRestApi.get_list.User1" }, "created_on": { "format": "date-time", @@ -3044,8 +3127,7 @@ }, "owners": { "items": { - "format": "int32", - "type": "integer" + "type": "object" }, "type": "array" }, @@ -3180,16 +3262,24 @@ }, "DashboardPermalinkPostSchema": { "properties": { - "filterState": { - "description": "Native filter state", + "activeTabs": { + "description": "Current active dashboard tabs", + "items": { + "type": "string" + }, "nullable": true, - "type": "object" + "type": "array" }, - "hash": { - "description": "Optional anchor link", + "anchor": { + "description": "Optional anchor link added to url hash", "nullable": true, "type": "string" }, + "dataMask": { + "description": "Data mask used for native filter state", + "nullable": true, + "type": "object" + }, "urlParams": { "description": "URL Parameters", "items": { @@ -3242,9 +3332,6 @@ "created_on_delta_humanized": { "readOnly": true }, - "created_on_delta_humanized": { - "readOnly": true - }, "css": { "nullable": true, "type": "string" @@ -3266,7 +3353,7 @@ "type": "string" }, "owners": { - "$ref": "#/components/schemas/DashboardRestApi.get_list.User2" + "$ref": "#/components/schemas/DashboardRestApi.get_list.User1" }, "position_json": { "nullable": true, @@ -3560,17 +3647,6 @@ }, "name": { "type": "string" - }, - "engine_information": { - "type": "object" - } - }, - "type": "object" - }, - "Database1": { - "properties": { - "database_name": { - "type": "string" } }, "type": "object" @@ -3711,9 +3787,11 @@ "maxLength": 250, "type": "string" }, - "encrypted_extra": { - "nullable": true, - "type": "string" + "driver": { + "readOnly": true + }, + "engine_information": { + "readOnly": true }, "expose_in_sqllab": { "nullable": true, @@ -3739,6 +3817,9 @@ "is_managed_externally": { "type": "boolean" }, + "masked_encrypted_extra": { + "readOnly": true + }, "parameters": { "readOnly": true }, @@ -3753,8 +3834,10 @@ "maxLength": 1024, "type": "string" }, - "engine_information": { - "readOnly": true + "uuid": { + "format": "uuid", + "nullable": true, + "type": "string" } }, "required": [ @@ -3815,6 +3898,9 @@ "disable_data_preview": { "readOnly": true }, + "engine_information": { + "readOnly": true + }, "explore_database_id": { "readOnly": true }, @@ -3835,8 +3921,10 @@ "format": "int32", "type": "integer" }, - "engine_information": { - "readOnly": true + "uuid": { + "format": "uuid", + "nullable": true, + "type": "string" } }, "required": [ @@ -3899,8 +3987,8 @@ "minLength": 1, "type": "string" }, - "encrypted_extra": { - "description": "

JSON string containing additional connection configuration.
This is used to provide connection information for systems like Hive, Presto, and BigQuery, which do not conform to the username:password syntax normally used by SQLAlchemy.

", + "driver": { + "description": "SQLAlchemy driver to use", "nullable": true, "type": "string" }, @@ -3936,6 +4024,11 @@ "nullable": true, "type": "boolean" }, + "masked_encrypted_extra": { + "description": "

JSON string containing additional connection configuration.
This is used to provide connection information for systems like Hive, Presto, and BigQuery, which do not conform to the username:password syntax normally used by SQLAlchemy.

", + "nullable": true, + "type": "string" + }, "parameters": { "additionalProperties": {}, "description": "DB-specific parameters for configuration", @@ -3951,6 +4044,17 @@ "maxLength": 1024, "minLength": 1, "type": "string" + }, + "ssh_tunnel": { + "allOf": [ + { + "$ref": "#/components/schemas/DatabaseSSHTunnel" + } + ], + "nullable": true + }, + "uuid": { + "type": "string" } }, "required": [ @@ -3997,8 +4101,8 @@ "nullable": true, "type": "string" }, - "encrypted_extra": { - "description": "

JSON string containing additional connection configuration.
This is used to provide connection information for systems like Hive, Presto, and BigQuery, which do not conform to the username:password syntax normally used by SQLAlchemy.

", + "driver": { + "description": "SQLAlchemy driver to use", "nullable": true, "type": "string" }, @@ -4034,6 +4138,11 @@ "nullable": true, "type": "boolean" }, + "masked_encrypted_extra": { + "description": "

JSON string containing additional connection configuration.
This is used to provide connection information for systems like Hive, Presto, and BigQuery, which do not conform to the username:password syntax normally used by SQLAlchemy.

", + "nullable": true, + "type": "string" + }, "parameters": { "additionalProperties": {}, "description": "DB-specific parameters for configuration", @@ -4049,6 +4158,38 @@ "maxLength": 1024, "minLength": 0, "type": "string" + }, + "ssh_tunnel": { + "allOf": [ + { + "$ref": "#/components/schemas/DatabaseSSHTunnel" + } + ], + "nullable": true + } + }, + "type": "object" + }, + "DatabaseSSHTunnel": { + "properties": { + "password": { + "type": "string" + }, + "private_key": { + "type": "string" + }, + "private_key_password": { + "type": "string" + }, + "server_address": { + "type": "string" + }, + "server_port": { + "format": "int32", + "type": "integer" + }, + "username": { + "type": "string" } }, "type": "object" @@ -4066,8 +4207,8 @@ "nullable": true, "type": "string" }, - "encrypted_extra": { - "description": "

JSON string containing additional connection configuration.
This is used to provide connection information for systems like Hive, Presto, and BigQuery, which do not conform to the username:password syntax normally used by SQLAlchemy.

", + "driver": { + "description": "SQLAlchemy driver to use", "nullable": true, "type": "string" }, @@ -4084,6 +4225,11 @@ "description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.
If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.", "type": "boolean" }, + "masked_encrypted_extra": { + "description": "

JSON string containing additional connection configuration.
This is used to provide connection information for systems like Hive, Presto, and BigQuery, which do not conform to the username:password syntax normally used by SQLAlchemy.

", + "nullable": true, + "type": "string" + }, "parameters": { "additionalProperties": {}, "description": "DB-specific parameters for configuration", @@ -4099,12 +4245,27 @@ "maxLength": 1024, "minLength": 1, "type": "string" - } - }, - "type": "object" - }, + }, + "ssh_tunnel": { + "allOf": [ + { + "$ref": "#/components/schemas/DatabaseSSHTunnel" + } + ], + "nullable": true + } + }, + "type": "object" + }, "DatabaseValidateParametersSchema": { "properties": { + "catalog": { + "additionalProperties": { + "nullable": true + }, + "description": "Gsheets specific column for managing label to sheet urls", + "type": "object" + }, "configuration_method": { "description": "Configuration_method is used on the frontend to inform the backend whether to explode parameters or to provide only a sqlalchemy_uri." }, @@ -4115,8 +4276,8 @@ "nullable": true, "type": "string" }, - "encrypted_extra": { - "description": "

JSON string containing additional connection configuration.
This is used to provide connection information for systems like Hive, Presto, and BigQuery, which do not conform to the username:password syntax normally used by SQLAlchemy.

", + "driver": { + "description": "SQLAlchemy driver to use", "nullable": true, "type": "string" }, @@ -4128,10 +4289,21 @@ "description": "

JSON string containing extra configuration elements.
1. The engine_params object gets unpacked into the sqlalchemy.create_engine call, while the metadata_params gets unpacked into the sqlalchemy.MetaData call.
2. The metadata_cache_timeout is a cache timeout setting in seconds for metadata fetch of this database. Specify it as \"metadata_cache_timeout\": {\"schema_cache_timeout\": 600, \"table_cache_timeout\": 600}. If unset, cache will not be enabled for the functionality. A timeout of 0 indicates that the cache never expires.
3. The schemas_allowed_for_file_upload is a comma separated list of schemas that CSVs are allowed to upload to. Specify it as \"schemas_allowed_for_file_upload\": [\"public\", \"csv_upload\"]. If database flavor does not support schema or any schema is allowed to be accessed, just leave the list empty
4. The version field is a string specifying the this db's version. This should be used with Presto DBs so that the syntax is correct
5. The allows_virtual_table_explore field is a boolean specifying whether or not the Explore button in SQL Lab results is shown.
6. The disable_data_preview field is a boolean specifying whether or not data preview queries will be run when fetching table metadata in SQL Lab.

", "type": "string" }, + "id": { + "description": "Database ID (for updates)", + "format": "int32", + "nullable": true, + "type": "integer" + }, "impersonate_user": { "description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.
If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.", "type": "boolean" }, + "masked_encrypted_extra": { + "description": "

JSON string containing additional connection configuration.
This is used to provide connection information for systems like Hive, Presto, and BigQuery, which do not conform to the username:password syntax normally used by SQLAlchemy.

", + "nullable": true, + "type": "string" + }, "parameters": { "additionalProperties": { "nullable": true @@ -4151,6 +4323,174 @@ ], "type": "object" }, + "Dataset": { + "properties": { + "cache_timeout": { + "description": "Duration (in seconds) of the caching timeout for this dataset.", + "format": "int32", + "type": "integer" + }, + "column_formats": { + "description": "Column formats.", + "type": "object" + }, + "columns": { + "description": "Columns metadata.", + "items": { + "type": "object" + }, + "type": "array" + }, + "database": { + "description": "Database associated with the dataset.", + "type": "object" + }, + "datasource_name": { + "description": "Dataset name.", + "type": "string" + }, + "default_endpoint": { + "description": "Default endpoint for the dataset.", + "type": "string" + }, + "description": { + "description": "Dataset description.", + "type": "string" + }, + "edit_url": { + "description": "The URL for editing the dataset.", + "type": "string" + }, + "extra": { + "description": "JSON string containing extra configuration elements.", + "type": "object" + }, + "fetch_values_predicate": { + "description": "Predicate used when fetching values from the dataset.", + "type": "string" + }, + "filter_select": { + "description": "SELECT filter applied to the dataset.", + "type": "boolean" + }, + "filter_select_enabled": { + "description": "If the SELECT filter is enabled.", + "type": "boolean" + }, + "granularity_sqla": { + "description": "Name of temporal column used for time filtering for SQL datasources. This field is deprecated, use `granularity` instead.", + "items": { + "items": { + "type": "object" + }, + "type": "array" + }, + "type": "array" + }, + "health_check_message": { + "description": "Health check message.", + "type": "string" + }, + "id": { + "description": "Dataset ID.", + "format": "int32", + "type": "integer" + }, + "is_sqllab_view": { + "description": "If the dataset is a SQL Lab view.", + "type": "boolean" + }, + "main_dttm_col": { + "description": "The main temporal column.", + "type": "string" + }, + "metrics": { + "description": "Dataset metrics.", + "items": { + "type": "object" + }, + "type": "array" + }, + "name": { + "description": "Dataset name.", + "type": "string" + }, + "offset": { + "description": "Dataset offset.", + "format": "int32", + "type": "integer" + }, + "order_by_choices": { + "description": "List of order by columns.", + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + }, + "owners": { + "description": "List of owners identifiers", + "items": { + "format": "int32", + "type": "integer" + }, + "type": "array" + }, + "params": { + "description": "Extra params for the dataset.", + "type": "object" + }, + "perm": { + "description": "Permission expression.", + "type": "string" + }, + "schema": { + "description": "Dataset schema.", + "type": "string" + }, + "select_star": { + "description": "Select all clause.", + "type": "string" + }, + "sql": { + "description": "A SQL statement that defines the dataset.", + "type": "string" + }, + "table_name": { + "description": "The name of the table associated with the dataset.", + "type": "string" + }, + "template_params": { + "description": "Table template params.", + "type": "object" + }, + "time_grain_sqla": { + "description": "List of temporal granularities supported by the dataset.", + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + }, + "type": { + "description": "Dataset type.", + "type": "string" + }, + "uid": { + "description": "Dataset unique identifier.", + "type": "string" + }, + "verbose_map": { + "description": "Mapping from raw name to verbose name.", + "type": "object" + } + }, + "type": "object" + }, "DatasetColumnsPut": { "properties": { "advanced_data_type": { @@ -4446,9 +4786,31 @@ "nullable": true, "type": "integer" }, + "changed_by": { + "$ref": "#/components/schemas/DatasetRestApi.get.User" + }, + "changed_on": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "changed_on_humanized": { + "readOnly": true + }, "columns": { "$ref": "#/components/schemas/DatasetRestApi.get.TableColumn" }, + "created_by": { + "$ref": "#/components/schemas/DatasetRestApi.get.User2" + }, + "created_on": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_on_humanized": { + "readOnly": true + }, "database": { "$ref": "#/components/schemas/DatasetRestApi.get.Database" }, @@ -4503,13 +4865,16 @@ "type": "integer" }, "owners": { - "$ref": "#/components/schemas/DatasetRestApi.get.User" + "$ref": "#/components/schemas/DatasetRestApi.get.User1" }, "schema": { "maxLength": 255, "nullable": true, "type": "string" }, + "select_star": { + "readOnly": true + }, "sql": { "nullable": true, "type": "string" @@ -4594,11 +4959,6 @@ "nullable": true, "type": "string" }, - "uuid": { - "format": "uuid", - "nullable": true, - "type": "string" - }, "verbose_name": { "maxLength": 1024, "nullable": true, @@ -4697,6 +5057,23 @@ "type": "object" }, "DatasetRestApi.get.User": { + "properties": { + "first_name": { + "maxLength": 64, + "type": "string" + }, + "last_name": { + "maxLength": 64, + "type": "string" + } + }, + "required": [ + "first_name", + "last_name" + ], + "type": "object" + }, + "DatasetRestApi.get.User1": { "properties": { "first_name": { "maxLength": 64, @@ -4722,6 +5099,23 @@ ], "type": "object" }, + "DatasetRestApi.get.User2": { + "properties": { + "first_name": { + "maxLength": 64, + "type": "string" + }, + "last_name": { + "maxLength": 64, + "type": "string" + } + }, + "required": [ + "first_name", + "last_name" + ], + "type": "object" + }, "DatasetRestApi.get_list": { "properties": { "changed_by": { @@ -4875,6 +5269,10 @@ "minLength": 0, "type": "string" }, + "sql": { + "nullable": true, + "type": "string" + }, "table_name": { "maxLength": 250, "minLength": 1, @@ -5006,115 +5404,41 @@ "type": "string" }, "schema": { - "description": "Datasource schema", - "type": "string" - } - }, - "required": [ - "datasource_type" - ], - "type": "object" - }, - "DistincResponseSchema": { - "properties": { - "count": { - "description": "The total number of distinct values", - "format": "int32", - "type": "integer" - }, - "result": { - "items": { - "$ref": "#/components/schemas/DistinctResultResponse" - }, - "type": "array" - } - }, - "type": "object" - }, - "DistinctResultResponse": { - "properties": { - "text": { - "description": "The distinct item", - "type": "string" - } - }, - "type": "object" - }, - "EmbeddedDashboardConfig": { - "properties": { - "allowed_domains": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "allowed_domains" - ], - "type": "object" - }, - "EmbeddedDashboardResponseSchema": { - "properties": { - "allowed_domains": { - "items": { - "type": "string" - }, - "type": "array" - }, - "changed_by": { - "$ref": "#/components/schemas/User" - }, - "changed_on": { - "format": "date-time", - "type": "string" - }, - "dashboard_id": { - "type": "string" - }, - "uuid": { - "type": "string" - } - }, - "type": "object" - }, - "EmbeddedDashboardRestApi.get": { - "properties": { - "uuid": { - "format": "uuid", - "type": "string" - } - }, - "type": "object" - }, - "EmbeddedDashboardRestApi.get_list": { - "properties": { - "uuid": { - "format": "uuid", + "description": "Datasource schema", "type": "string" } }, + "required": [ + "datasource_type" + ], "type": "object" }, - "EmbeddedDashboardRestApi.post": { + "DistincResponseSchema": { "properties": { - "uuid": { - "format": "uuid", - "type": "string" + "count": { + "description": "The total number of distinct values", + "format": "int32", + "type": "integer" + }, + "result": { + "items": { + "$ref": "#/components/schemas/DistinctResultResponse" + }, + "type": "array" } }, "type": "object" }, - "EmbeddedDashboardRestApi.put": { + "DistinctResultResponse": { "properties": { - "uuid": { - "format": "uuid", + "text": { + "description": "The distinct item", "type": "string" } }, "type": "object" }, - "ExplorePermalinkPostSchema": { + "EmbeddedDashboardConfig": { "properties": { "allowed_domains": { "items": { @@ -5188,6 +5512,25 @@ }, "type": "object" }, + "ExploreContextSchema": { + "properties": { + "dataset": { + "$ref": "#/components/schemas/Dataset" + }, + "form_data": { + "description": "Form data from the Explore controls used to form the chart's data query.", + "type": "object" + }, + "message": { + "description": "Any message related to the processed request.", + "type": "string" + }, + "slice": { + "$ref": "#/components/schemas/Slice" + } + }, + "type": "object" + }, "ExplorePermalinkPostSchema": { "properties": { "formData": { @@ -5730,8 +6073,7 @@ "type": "string" }, "tracking_url": { - "nullable": true, - "type": "string" + "readOnly": true } }, "required": [ @@ -5840,6 +6182,10 @@ }, "RelatedResultResponse": { "properties": { + "extra": { + "description": "The extra metadata for related item", + "type": "object" + }, "text": { "description": "The related item string representation", "type": "string" @@ -6027,6 +6373,9 @@ "nullable": true, "type": "string" }, + "extra": { + "readOnly": true + }, "force_screenshot": { "nullable": true, "type": "boolean" @@ -6255,6 +6604,9 @@ "nullable": true, "type": "string" }, + "extra": { + "readOnly": true + }, "id": { "format": "int32", "type": "integer" @@ -7143,6 +7495,9 @@ "nullable": true, "type": "string" }, + "extra": { + "type": "object" + }, "force_screenshot": { "type": "boolean" }, @@ -7867,6 +8222,9 @@ }, "SavedQueryRestApi.get": { "properties": { + "changed_on_delta_humanized": { + "readOnly": true + }, "created_by": { "$ref": "#/components/schemas/SavedQueryRestApi.get.User" }, @@ -7897,6 +8255,10 @@ }, "sql_tables": { "readOnly": true + }, + "template_parameters": { + "nullable": true, + "type": "string" } }, "type": "object" @@ -8059,6 +8421,10 @@ "sql": { "nullable": true, "type": "string" + }, + "template_parameters": { + "nullable": true, + "type": "string" } }, "type": "object" @@ -8087,6 +8453,10 @@ "sql": { "nullable": true, "type": "string" + }, + "template_parameters": { + "nullable": true, + "type": "string" } }, "type": "object" @@ -8112,6 +8482,93 @@ }, "type": "object" }, + "Slice": { + "properties": { + "cache_timeout": { + "description": "Duration (in seconds) of the caching timeout for this chart.", + "format": "int32", + "type": "integer" + }, + "certification_details": { + "description": "Details of the certification.", + "type": "string" + }, + "certified_by": { + "description": "Person or group that has certified this dashboard.", + "type": "string" + }, + "changed_on": { + "description": "Timestamp of the last modification.", + "type": "string" + }, + "changed_on_humanized": { + "description": "Timestamp of the last modification in human readable form.", + "type": "string" + }, + "datasource": { + "description": "Datasource identifier.", + "type": "string" + }, + "description": { + "description": "Slice description.", + "type": "string" + }, + "description_markeddown": { + "description": "Sanitized HTML version of the chart description.", + "type": "string" + }, + "edit_url": { + "description": "The URL for editing the slice.", + "type": "string" + }, + "form_data": { + "description": "Form data associated with the slice.", + "type": "object" + }, + "is_managed_externally": { + "description": "If the chart is managed outside externally.", + "type": "boolean" + }, + "modified": { + "description": "Last modification in human readable form.", + "type": "string" + }, + "owners": { + "description": "Owners identifiers.", + "items": { + "format": "int32", + "type": "integer" + }, + "type": "array" + }, + "query_context": { + "description": "The context associated with the query.", + "type": "object" + }, + "slice_id": { + "description": "The slice ID.", + "format": "int32", + "type": "integer" + }, + "slice_name": { + "description": "The slice name.", + "type": "string" + }, + "slice_url": { + "description": "The slice URL.", + "type": "string" + } + }, + "type": "object" + }, + "StopQuerySchema": { + "properties": { + "client_id": { + "type": "string" + } + }, + "type": "object" + }, "TableExtraMetadataResponseSchema": { "properties": { "clustering": { @@ -8767,99 +9224,6 @@ ] } }, - "/api/v1/annotation_layer/": { - "delete": { - "description": "Deletes multiple annotation layers in a bulk operation.", - "parameters": [ - { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/advanced_data_type_convert_schema" - } - } - }, - "in": "query", - "name": "q" - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdvancedDataTypeSchema" - } - } - }, - "description": "AdvancedDataTypeResponse object has been returned." - }, - "400": { - "$ref": "#/components/responses/400" - }, - "401": { - "$ref": "#/components/responses/401" - }, - "404": { - "$ref": "#/components/responses/404" - }, - "500": { - "$ref": "#/components/responses/500" - } - }, - "security": [ - { - "jwt": [] - } - ], - "summary": "Returns a AdvancedDataTypeResponse object populated with the passed in args.", - "tags": [ - "Advanced Data Type" - ] - } - }, - "/api/v1/advanced_data_type/types": { - "get": { - "description": "Returns a list of available advanced data types.", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "result": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - } - } - }, - "description": "a successful return of the available advanced data types has taken place." - }, - "401": { - "$ref": "#/components/responses/401" - }, - "404": { - "$ref": "#/components/responses/404" - }, - "500": { - "$ref": "#/components/responses/500" - } - }, - "security": [ - { - "jwt": [] - } - ], - "tags": [ - "Advanced Data Type" - ] - } - }, "/api/v1/annotation_layer/": { "delete": { "description": "Deletes multiple annotation layers in a bulk operation.", @@ -10004,13 +10368,49 @@ } } }, - "description": "Async event results" + "description": "Async event results" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + } + ], + "tags": [ + "AsyncEventsRestApi" + ] + } + }, + "/api/v1/available_domains/": { + "get": { + "description": "Get all available domains", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/AvailableDomainsSchema" + } + }, + "type": "object" + } + } + }, + "description": "a list of available domains" }, "401": { "$ref": "#/components/responses/401" }, - "500": { - "$ref": "#/components/responses/500" + "403": { + "$ref": "#/components/responses/403" } }, "security": [ @@ -10019,7 +10419,7 @@ } ], "tags": [ - "AsyncEventsRestApi" + "Available Domains" ] } }, @@ -11004,6 +11404,14 @@ "schema": { "type": "string" } + }, + { + "description": "Should the queries be forced to load from the source", + "in": "query", + "name": "force", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -13806,6 +14214,16 @@ "description": "Name of the SQLAlchemy engine", "type": "string" }, + "engine_information": { + "description": "Dict with public properties form the DB Engine", + "properties": { + "supports_file_upload": { + "description": "Whether the engine supports file uploads", + "type": "boolean" + } + }, + "type": "object" + }, "name": { "description": "Name of the database", "type": "string" @@ -13821,10 +14239,6 @@ "sqlalchemy_uri_placeholder": { "description": "Example placeholder for the SQLAlchemy URI", "type": "string" - }, - "engine_information": { - "description": "Object with properties we want to expose from our DB engine", - "type": "object" } }, "type": "object" @@ -14121,26 +14535,16 @@ ] }, "get": { - "description": "Get an item model", + "description": "Get a database", "parameters": [ { + "description": "The database id", "in": "path", "name": "pk", "required": true, "schema": { "type": "integer" } - }, - { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/get_item_schema" - } - } - }, - "in": "query", - "name": "q" } ], "responses": { @@ -14148,52 +14552,11 @@ "content": { "application/json": { "schema": { - "properties": { - "description_columns": { - "properties": { - "column_name": { - "description": "The description for the column name. Will be translated by babel", - "example": "A Nice description for the column", - "type": "string" - } - }, - "type": "object" - }, - "id": { - "description": "The item id", - "type": "string" - }, - "label_columns": { - "properties": { - "column_name": { - "description": "The label for the column name. Will be translated by babel", - "example": "A Nice label for the column", - "type": "string" - } - }, - "type": "object" - }, - "result": { - "$ref": "#/components/schemas/DatabaseRestApi.get" - }, - "show_columns": { - "description": "A list of columns", - "items": { - "type": "string" - }, - "type": "array" - }, - "show_title": { - "description": "A title to render. Will be translated by babel", - "example": "Show Item Details", - "type": "string" - } - }, "type": "object" } } }, - "description": "Item from Model" + "description": "Database" }, "400": { "$ref": "#/components/responses/400" @@ -14201,9 +14564,6 @@ "401": { "$ref": "#/components/responses/401" }, - "404": { - "$ref": "#/components/responses/404" - }, "422": { "$ref": "#/components/responses/422" }, @@ -14576,36 +14936,17 @@ ] } }, - "/api/v1/database/{pk}/select_star/{table_name}/{schema_name}/": { - "get": { - "description": "Get database select star for table", + "/api/v1/database/{pk}/ssh_tunnel/": { + "delete": { + "description": "Deletes a SSH Tunnel.", "parameters": [ { - "description": "The database id", "in": "path", "name": "pk", "required": true, "schema": { "type": "integer" } - }, - { - "description": "Table name", - "in": "path", - "name": "table_name", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Table schema", - "in": "path", - "name": "schema_name", - "required": true, - "schema": { - "type": "string" - } } ], "responses": { @@ -14613,18 +14954,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SelectStarResponseSchema" + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" } } }, - "description": "SQL statement for a select star for table" - }, - "400": { - "$ref": "#/components/responses/400" + "description": "SSH Tunnel deleted" }, "401": { "$ref": "#/components/responses/401" }, + "403": { + "$ref": "#/components/responses/403" + }, "404": { "$ref": "#/components/responses/404" }, @@ -14784,7 +15130,7 @@ ] } }, - "/api/v1/database/{pk}/validate_sql": { + "/api/v1/database/{pk}/validate_sql/": { "post": { "description": "Validates arbitrary SQL.", "parameters": [ @@ -15220,16 +15566,6 @@ "/api/v1/dataset/duplicate": { "post": { "description": "Duplicates a Dataset", - "parameters": [ - { - "in": "path", - "name": "pk", - "required": true, - "schema": { - "type": "integer" - } - } - ], "requestBody": { "content": { "application/json": { @@ -15242,20 +15578,23 @@ "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { "properties": { - "message": { - "type": "string" + "id": { + "type": "number" + }, + "result": { + "$ref": "#/components/schemas/DatasetDuplicateSchema" } }, "type": "object" } } }, - "description": "Dataset duplicate" + "description": "Dataset duplicated" }, "400": { "$ref": "#/components/responses/400" @@ -15928,23 +16267,17 @@ ] } }, - "/api/v1/dataset/{pk}/samples": { + "/api/v1/embedded_dashboard/{uuid}": { "get": { - "description": "get samples from a Dataset", + "description": "Get a report schedule log", "parameters": [ { + "description": "The embedded configuration uuid", "in": "path", - "name": "pk", + "name": "uuid", "required": true, "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "force", - "schema": { - "type": "boolean" + "type": "string" } } ], @@ -15955,27 +16288,21 @@ "schema": { "properties": { "result": { - "$ref": "#/components/schemas/ChartDataResponseResult" + "$ref": "#/components/schemas/EmbeddedDashboardResponseSchema" } }, "type": "object" } } }, - "description": "Dataset samples" + "description": "Result contains the embedded dashboard configuration" }, "401": { "$ref": "#/components/responses/401" }, - "403": { - "$ref": "#/components/responses/403" - }, "404": { "$ref": "#/components/responses/404" }, - "422": { - "$ref": "#/components/responses/422" - }, "500": { "$ref": "#/components/responses/500" } @@ -15986,19 +16313,45 @@ } ], "tags": [ - "Datasets" + "Embedded Dashboard" ] } }, - "/api/v1/embedded_dashboard/{uuid}": { + "/api/v1/explore/": { "get": { - "description": "Get a report schedule log", + "description": "Assembles Explore related information (form_data, slice, dataset)\\n in a single endpoint.

\\nThe information can be assembled from:
- The cache using a form_data_key
- The metadata database using a permalink_key
- Build from scratch using dataset or slice identifiers.", "parameters": [ { - "description": "The embedded configuration uuid", - "in": "path", - "name": "uuid", - "required": true, + "in": "query", + "name": "form_data_key", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "permalink_key", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "slice_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "datasource_id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "datasource_type", "schema": { "type": "string" } @@ -16009,16 +16362,14 @@ "content": { "application/json": { "schema": { - "properties": { - "result": { - "$ref": "#/components/schemas/EmbeddedDashboardResponseSchema" - } - }, - "type": "object" + "$ref": "#/components/schemas/ExploreContextSchema" } } }, - "description": "Result contains the embedded dashboard configuration" + "description": "Returns the initial context." + }, + "400": { + "$ref": "#/components/responses/400" }, "401": { "$ref": "#/components/responses/401" @@ -16026,6 +16377,9 @@ "404": { "$ref": "#/components/responses/404" }, + "422": { + "$ref": "#/components/responses/422" + }, "500": { "$ref": "#/components/responses/500" } @@ -16035,8 +16389,9 @@ "jwt": [] } ], + "summary": "Assembles Explore related information (form_data, slice, dataset)\\n in a single endpoint.", "tags": [ - "Embedded Dashboard" + "Explore" ] } }, @@ -17001,6 +17356,59 @@ ] } }, + "/api/v1/query/stop": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StopQuerySchema" + } + } + }, + "description": "Stop query schema", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Query stopped" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + } + ], + "summary": "Manually stop a query with client_id", + "tags": [ + "Queries" + ] + } + }, "/api/v1/query/{pk}": { "get": { "description": "Get query detail information.", diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index 12487d1a9437d..d6447e808c607 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -450,9 +450,9 @@ export function validateQuery(queryEditor, sql) { export function postStopQuery(query) { return function (dispatch) { return SupersetClient.post({ - endpoint: '/superset/stop_query/', - postPayload: { client_id: query.id }, - stringify: false, + endpoint: '/api/v1/query/stop', + body: JSON.stringify({ client_id: query.id }), + headers: { 'Content-Type': 'application/json' }, }) .then(() => dispatch(stopQuery(query))) .then(() => dispatch(addSuccessToast(t('Query was stopped.')))) diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.test.js b/superset-frontend/src/SqlLab/actions/sqlLab.test.js index acc79031edc15..fb6ff470b40f0 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.test.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.test.js @@ -317,11 +317,15 @@ describe('async actions', () => { }); describe('postStopQuery', () => { - const stopQueryEndpoint = 'glob:*/superset/stop_query/*'; + const stopQueryEndpoint = 'glob:*/api/v1/query/stop'; fetchMock.post(stopQueryEndpoint, {}); + const baseQuery = { + ...query, + id: 'test_foo', + }; const makeRequest = () => { - const request = actions.postStopQuery(query); + const request = actions.postStopQuery(baseQuery); return request(dispatch); }; @@ -346,7 +350,8 @@ describe('async actions', () => { return makeRequest().then(() => { const call = fetchMock.calls(stopQueryEndpoint)[0]; - expect(call[1].body.get('client_id')).toBe(query.id); + const body = JSON.parse(call[1].body); + expect(body.client_id).toBe(baseQuery.id); }); }); }); diff --git a/superset/constants.py b/superset/constants.py index ea7920ff2fd7a..10de5c52f04a7 100644 --- a/superset/constants.py +++ b/superset/constants.py @@ -140,6 +140,7 @@ class RouteMethod: # pylint: disable=too-few-public-methods "get_data": "read", "samples": "read", "delete_ssh_tunnel": "write", + "stop_query": "read", } EXTRA_FORM_DATA_APPEND_KEYS = { diff --git a/superset/exceptions.py b/superset/exceptions.py index 153d7439eb790..963bf966820d5 100644 --- a/superset/exceptions.py +++ b/superset/exceptions.py @@ -266,3 +266,7 @@ def __init__(self, error: ValidationError): class SupersetCancelQueryException(SupersetException): status = 422 + + +class QueryNotFoundException(SupersetException): + status = 404 diff --git a/superset/queries/api.py b/superset/queries/api.py index 1fb342f067d3d..51ba148603285 100644 --- a/superset/queries/api.py +++ b/superset/queries/api.py @@ -16,14 +16,29 @@ # under the License. import logging +import backoff +from flask_appbuilder.api import expose, protect, request, safe from flask_appbuilder.models.sqla.interface import SQLAInterface +from superset import db, event_logger from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.databases.filters import DatabaseFilter +from superset.exceptions import SupersetException from superset.models.sql_lab import Query +from superset.queries.dao import QueryDAO from superset.queries.filters import QueryFilter -from superset.queries.schemas import openapi_spec_methods_override, QuerySchema -from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter +from superset.queries.schemas import ( + openapi_spec_methods_override, + QuerySchema, + StopQuerySchema, +) +from superset.superset_typing import FlaskResponse +from superset.views.base_api import ( + BaseSupersetModelRestApi, + RelatedFieldFilter, + requires_json, + statsd_metrics, +) from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners logger = logging.getLogger(__name__) @@ -43,6 +58,7 @@ class QueryRestApi(BaseSupersetModelRestApi): RouteMethod.GET_LIST, RouteMethod.RELATED, RouteMethod.DISTINCT, + "stop_query", } list_columns = [ @@ -95,9 +111,11 @@ class QueryRestApi(BaseSupersetModelRestApi): base_filters = [["id", QueryFilter, lambda: []]] base_order = ("changed_on", "desc") list_model_schema = QuerySchema() + stop_query_schema = StopQuerySchema() openapi_spec_tag = "Queries" openapi_spec_methods = openapi_spec_methods_override + openapi_spec_component_schemas = (StopQuerySchema,) order_columns = [ "changed_on", @@ -123,3 +141,59 @@ class QueryRestApi(BaseSupersetModelRestApi): base_related_field_filters = {"database": [["id", DatabaseFilter, lambda: []]]} allowed_rel_fields = {"database", "user"} allowed_distinct_fields = {"status"} + + @expose("/stop", methods=["POST"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" + f".stop_query", + log_to_statsd=False, + ) + @backoff.on_exception( + backoff.constant, + Exception, + interval=1, + on_backoff=lambda details: db.session.rollback(), + on_giveup=lambda details: db.session.rollback(), + max_tries=5, + ) + @requires_json + def stop_query(self) -> FlaskResponse: + """Manually stop a query with client_id + --- + post: + summary: Manually stop a query with client_id + requestBody: + description: Stop query schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StopQuerySchema' + responses: + 200: + description: Query stopped + content: + application/json: + schema: + type: object + properties: + result: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + body = self.stop_query_schema.load(request.json) + QueryDAO.stop_query(body["client_id"]) + return self.response(200, result="OK") + except SupersetException as ex: + return self.response(ex.status, message=ex.message) diff --git a/superset/queries/dao.py b/superset/queries/dao.py index c7d59343e8587..5867b2917dba0 100644 --- a/superset/queries/dao.py +++ b/superset/queries/dao.py @@ -18,10 +18,14 @@ from datetime import datetime from typing import Any, Dict +from superset import sql_lab +from superset.common.db_query_status import QueryStatus from superset.dao.base import BaseDAO +from superset.exceptions import QueryNotFoundException, SupersetCancelQueryException from superset.extensions import db from superset.models.sql_lab import Query, SavedQuery from superset.queries.filters import QueryFilter +from superset.utils.dates import now_as_float logger = logging.getLogger(__name__) @@ -56,3 +60,26 @@ def save_metadata(query: Query, payload: Dict[str, Any]) -> None: columns = payload.get("columns", {}) db.session.add(query) query.set_extra_json_key("columns", columns) + + @staticmethod + def stop_query(client_id: str) -> None: + query = db.session.query(Query).filter_by(client_id=client_id).one_or_none() + if not query: + raise QueryNotFoundException(f"Query with client_id {client_id} not found") + + if query.status in [ + QueryStatus.FAILED, + QueryStatus.SUCCESS, + QueryStatus.TIMED_OUT, + ]: + logger.warning( + "Query with client_id could not be stopped: query already complete", + ) + return + + if not sql_lab.cancel_query(query): + raise SupersetCancelQueryException("Could not cancel query") + + query.status = QueryStatus.STOPPED + query.end_time = now_as_float() + db.session.commit() diff --git a/superset/queries/schemas.py b/superset/queries/schemas.py index f11cf37127756..a8c1e2bbcbd5d 100644 --- a/superset/queries/schemas.py +++ b/superset/queries/schemas.py @@ -67,3 +67,11 @@ class Meta: # pylint: disable=too-few-public-methods # pylint: disable=no-self-use def get_sql_tables(self, obj: Query) -> List[Table]: return obj.sql_tables + + +class StopQuerySchema(Schema): + """ + Schema for the stop_query API call. + """ + + client_id = fields.String() diff --git a/superset/views/core.py b/superset/views/core.py index 62a7c5b963524..b9e19810287e8 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -2298,6 +2298,7 @@ def results_exec(key: str) -> FlaskResponse: on_giveup=lambda details: db.session.rollback(), max_tries=5, ) + @deprecated() def stop_query(self) -> FlaskResponse: client_id = request.form.get("client_id") query = db.session.query(Query).filter_by(client_id=client_id).one() diff --git a/tests/integration_tests/queries/api_tests.py b/tests/integration_tests/queries/api_tests.py index eaf4e00576573..8c193662a12c7 100644 --- a/tests/integration_tests/queries/api_tests.py +++ b/tests/integration_tests/queries/api_tests.py @@ -17,6 +17,7 @@ # isort:skip_file """Unit tests for Superset""" from datetime import datetime, timedelta +from unittest import mock import json import random import string @@ -392,3 +393,54 @@ def test_get_list_query_no_data_access(self): # rollback changes db.session.delete(query) db.session.commit() + + @mock.patch("superset.sql_lab.cancel_query") + @mock.patch("superset.views.core.db.session") + def test_stop_query_not_found( + self, mock_superset_db_session, mock_sql_lab_cancel_query + ): + """ + Handles stop query when the DB engine spec does not + have a cancel query method (with invalid client_id). + """ + form_data = {"client_id": "foo2"} + query_mock = mock.Mock() + query_mock.return_value = None + self.login(username="admin") + mock_superset_db_session.query().filter_by().one_or_none = query_mock + mock_sql_lab_cancel_query.return_value = True + rv = self.client.post( + "/api/v1/query/stop", + data=json.dumps(form_data), + content_type="application/json", + ) + + assert rv.status_code == 404 + data = json.loads(rv.data.decode("utf-8")) + assert data["message"] == "Query with client_id foo2 not found" + + @mock.patch("superset.sql_lab.cancel_query") + @mock.patch("superset.views.core.db.session") + def test_stop_query(self, mock_superset_db_session, mock_sql_lab_cancel_query): + """ + Handles stop query when the DB engine spec does not + have a cancel query method. + """ + form_data = {"client_id": "foo"} + query_mock = mock.Mock() + query_mock.client_id = "foo" + query_mock.status = QueryStatus.RUNNING + self.login(username="admin") + mock_superset_db_session.query().filter_by().one_or_none().return_value = ( + query_mock + ) + mock_sql_lab_cancel_query.return_value = True + rv = self.client.post( + "/api/v1/query/stop", + data=json.dumps(form_data), + content_type="application/json", + ) + + assert rv.status_code == 200 + data = json.loads(rv.data.decode("utf-8")) + assert data["result"] == "OK" diff --git a/tests/unit_tests/dao/queries_test.py b/tests/unit_tests/dao/queries_test.py index 8e2a458434cd9..590ba92d48f13 100644 --- a/tests/unit_tests/dao/queries_test.py +++ b/tests/unit_tests/dao/queries_test.py @@ -15,11 +15,14 @@ # specific language governing permissions and limitations # under the License. import json -from typing import Iterator +from typing import Any, Iterator import pytest +from pytest_mock import MockFixture from sqlalchemy.orm.session import Session +from superset.exceptions import QueryNotFoundException, SupersetCancelQueryException + def test_query_dao_save_metadata(session: Session) -> None: from superset.models.core import Database @@ -53,3 +56,163 @@ def test_query_dao_save_metadata(session: Session) -> None: query = session.query(Query).one() QueryDAO.save_metadata(query=query, payload={"columns": []}) assert query.extra.get("columns", None) == [] + + +def test_query_dao_stop_query_not_found( + mocker: MockFixture, app: Any, session: Session +) -> None: + from superset.common.db_query_status import QueryStatus + from superset.models.core import Database + from superset.models.sql_lab import Query + + engine = session.get_bind() + Query.metadata.create_all(engine) # pylint: disable=no-member + + db = Database(database_name="my_database", sqlalchemy_uri="sqlite://") + + query_obj = Query( + client_id="foo", + database=db, + tab_name="test_tab", + sql_editor_id="test_editor_id", + sql="select * from bar", + select_sql="select * from bar", + executed_sql="select * from bar", + limit=100, + select_as_cta=False, + rows=100, + error_message="none", + results_key="abc", + status=QueryStatus.RUNNING, + ) + + session.add(db) + session.add(query_obj) + + mocker.patch("superset.sql_lab.cancel_query", return_value=False) + + from superset.queries.dao import QueryDAO + + with pytest.raises(QueryNotFoundException): + QueryDAO.stop_query("foo2") + + query = session.query(Query).one() + assert query.status == QueryStatus.RUNNING + + +def test_query_dao_stop_query_not_running( + mocker: MockFixture, app: Any, session: Session +) -> None: + from superset.common.db_query_status import QueryStatus + from superset.models.core import Database + from superset.models.sql_lab import Query + + engine = session.get_bind() + Query.metadata.create_all(engine) # pylint: disable=no-member + + db = Database(database_name="my_database", sqlalchemy_uri="sqlite://") + + query_obj = Query( + client_id="foo", + database=db, + tab_name="test_tab", + sql_editor_id="test_editor_id", + sql="select * from bar", + select_sql="select * from bar", + executed_sql="select * from bar", + limit=100, + select_as_cta=False, + rows=100, + error_message="none", + results_key="abc", + status=QueryStatus.FAILED, + ) + + session.add(db) + session.add(query_obj) + + from superset.queries.dao import QueryDAO + + QueryDAO.stop_query(query_obj.client_id) + query = session.query(Query).one() + assert query.status == QueryStatus.FAILED + + +def test_query_dao_stop_query_failed( + mocker: MockFixture, app: Any, session: Session +) -> None: + from superset.common.db_query_status import QueryStatus + from superset.models.core import Database + from superset.models.sql_lab import Query + + engine = session.get_bind() + Query.metadata.create_all(engine) # pylint: disable=no-member + + db = Database(database_name="my_database", sqlalchemy_uri="sqlite://") + + query_obj = Query( + client_id="foo", + database=db, + tab_name="test_tab", + sql_editor_id="test_editor_id", + sql="select * from bar", + select_sql="select * from bar", + executed_sql="select * from bar", + limit=100, + select_as_cta=False, + rows=100, + error_message="none", + results_key="abc", + status=QueryStatus.RUNNING, + ) + + session.add(db) + session.add(query_obj) + + mocker.patch("superset.sql_lab.cancel_query", return_value=False) + + from superset.queries.dao import QueryDAO + + with pytest.raises(SupersetCancelQueryException): + QueryDAO.stop_query(query_obj.client_id) + + query = session.query(Query).one() + assert query.status == QueryStatus.RUNNING + + +def test_query_dao_stop_query(mocker: MockFixture, app: Any, session: Session) -> None: + from superset.common.db_query_status import QueryStatus + from superset.models.core import Database + from superset.models.sql_lab import Query + + engine = session.get_bind() + Query.metadata.create_all(engine) # pylint: disable=no-member + + db = Database(database_name="my_database", sqlalchemy_uri="sqlite://") + + query_obj = Query( + client_id="foo", + database=db, + tab_name="test_tab", + sql_editor_id="test_editor_id", + sql="select * from bar", + select_sql="select * from bar", + executed_sql="select * from bar", + limit=100, + select_as_cta=False, + rows=100, + error_message="none", + results_key="abc", + status=QueryStatus.RUNNING, + ) + + session.add(db) + session.add(query_obj) + + mocker.patch("superset.sql_lab.cancel_query", return_value=True) + + from superset.queries.dao import QueryDAO + + QueryDAO.stop_query(query_obj.client_id) + query = session.query(Query).one() + assert query.status == QueryStatus.STOPPED