This is an automated email from the ASF dual-hosted git repository.
vavila pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new e79778ac6d feat(dataset API): Add parameter to optionally render Jinja
macros in API response (#30721)
e79778ac6d is described below
commit e79778ac6df9000080ec7ee5b24d59bc46e44423
Author: Vitor Avila <[email protected]>
AuthorDate: Wed Oct 30 12:42:27 2024 -0300
feat(dataset API): Add parameter to optionally render Jinja macros in API
response (#30721)
---
superset/datasets/api.py | 150 +++++++++++++++++++++++++-
superset/datasets/schemas.py | 1 -
tests/integration_tests/datasets/api_tests.py | 139 ++++++++++++++++++++++++
3 files changed, 288 insertions(+), 2 deletions(-)
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index f8f6bdc0b9..762727aafc 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -15,16 +15,24 @@
# specific language governing permissions and limitations
# under the License.
# pylint: disable=too-many-lines
+from __future__ import annotations
+
import logging
from datetime import datetime
from io import BytesIO
-from typing import Any
+from typing import Any, Callable
from zipfile import is_zipfile, ZipFile
from flask import request, Response, send_file
from flask_appbuilder.api import expose, protect, rison, safe
+from flask_appbuilder.api.schemas import get_item_schema
+from flask_appbuilder.const import (
+ API_RESULT_RES_KEY,
+ API_SELECT_COLUMNS_RIS_KEY,
+)
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
+from jinja2.exceptions import TemplateSyntaxError
from marshmallow import ValidationError
from superset import event_logger
@@ -65,6 +73,8 @@ from superset.datasets.schemas import (
GetOrCreateDatasetSchema,
openapi_spec_methods_override,
)
+from superset.exceptions import SupersetTemplateException
+from superset.jinja_context import BaseTemplateProcessor,
get_template_processor
from superset.utils import json
from superset.utils.core import parse_boolean_string
from superset.views.base import DatasourceFilter
@@ -75,6 +85,7 @@ from superset.views.base_api import (
requires_json,
statsd_metrics,
)
+from superset.views.error_handling import handle_api_exception
from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners
logger = logging.getLogger(__name__)
@@ -1056,3 +1067,140 @@ class DatasetRestApi(BaseSupersetModelRestApi):
return self.response(200, result=result)
except CommandException as ex:
return self.response(ex.status, message=ex.message)
+
+ @expose("/<int:pk>", methods=("GET",))
+ @protect()
+ @safe
+ @rison(get_item_schema)
+ @statsd_metrics
+ @handle_api_exception
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".get",
+ log_to_statsd=False,
+ )
+ def get(self, pk: int, **kwargs: Any) -> Response:
+ """Get a dataset.
+ ---
+ get:
+ summary: Get a dataset
+ description: Get a dataset by ID
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ description: The dataset ID
+ name: pk
+ - in: query
+ name: q
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/get_item_schema'
+ - in: query
+ name: include_rendered_sql
+ description: >-
+ Should Jinja macros from sql, metrics and columns be rendered
+ and included in the response
+ schema:
+ type: boolean
+ responses:
+ 200:
+ description: Dataset object has been returned.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ description: The item id
+ type: string
+ result:
+ $ref:
'#/components/schemas/{{self.__class__.__name__}}.get'
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 422:
+ $ref: '#/components/responses/422'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ item: SqlaTable | None = self.datamodel.get(
+ pk,
+ self._base_filters,
+ self.show_select_columns,
+ self.show_outer_default_load,
+ )
+ if not item:
+ return self.response_404()
+
+ response: dict[str, Any] = {}
+ args = kwargs.get("rison", {})
+ select_cols = args.get(API_SELECT_COLUMNS_RIS_KEY, [])
+ pruned_select_cols = [col for col in select_cols if col in
self.show_columns]
+ self.set_response_key_mappings(
+ response,
+ self.get,
+ args,
+ **{API_SELECT_COLUMNS_RIS_KEY: pruned_select_cols},
+ )
+ if pruned_select_cols:
+ show_model_schema =
self.model2schemaconverter.convert(pruned_select_cols)
+ else:
+ show_model_schema = self.show_model_schema
+
+ response["id"] = pk
+ response[API_RESULT_RES_KEY] = show_model_schema.dump(item, many=False)
+
+ if parse_boolean_string(request.args.get("include_rendered_sql")):
+ try:
+ processor = get_template_processor(database=item.database)
+ response["result"] = self.render_dataset_fields(
+ response["result"], processor
+ )
+ except SupersetTemplateException as ex:
+ return self.response_400(message=str(ex))
+ return self.response(200, **response)
+
+ @staticmethod
+ def render_dataset_fields(
+ data: dict[str, Any], processor: BaseTemplateProcessor
+ ) -> dict[str, Any]:
+ """
+ Renders Jinja macros in the ``sql``, ``metrics`` and ``columns``
fields.
+
+ :param data: Dataset info to be rendered
+ :param processor: A ``TemplateProcessor`` instance
+ :return: Rendered dataset data
+ """
+
+ def render_item_list(item_list: list[dict[str, Any]]) ->
list[dict[str, Any]]:
+ return [
+ {
+ **item,
+ "rendered_expression": processor.process_template(
+ item["expression"]
+ ),
+ }
+ if item.get("expression")
+ else item
+ for item in item_list
+ ]
+
+ items: list[tuple[str, str, str, Callable[[Any], Any]]] = [
+ ("query", "sql", "rendered_sql", processor.process_template),
+ ("metric", "metrics", "metrics", render_item_list),
+ ("calculated column", "columns", "columns", render_item_list),
+ ]
+ for item_type, key, new_key, func in items:
+ if not data.get(key):
+ continue
+
+ try:
+ data[new_key] = func(data[key])
+ except TemplateSyntaxError as ex:
+ raise SupersetTemplateException(
+ f"Unable to render expression from dataset {item_type}.",
+ ) from ex
+
+ return data
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index 5b899d8402..4b7e92d7ff 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -29,7 +29,6 @@ get_delete_ids_schema = {"type": "array", "items": {"type":
"integer"}}
get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
openapi_spec_methods_override = {
- "get": {"get": {"summary": "Get a dataset detail information"}},
"get_list": {
"get": {
"summary": "Get a list of datasets",
diff --git a/tests/integration_tests/datasets/api_tests.py
b/tests/integration_tests/datasets/api_tests.py
index 49110277bf..b04d4cec73 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -410,6 +410,145 @@ class TestDatasetApi(SupersetTestCase):
assert len(response["result"]["columns"]) == 3
assert len(response["result"]["metrics"]) == 2
+ def test_get_dataset_render_jinja(self):
+ """
+ Dataset API: Test get dataset with the render parameter.
+ """
+ database = get_example_database()
+ dataset = SqlaTable(
+ table_name="test_sql_table_with_jinja",
+ database=database,
+ schema=get_example_default_schema(),
+ main_dttm_col="default_dttm",
+ columns=[
+ TableColumn(
+ column_name="my_user_id",
+ type="INTEGER",
+ is_dttm=False,
+ ),
+ TableColumn(
+ column_name="calculated_test",
+ type="VARCHAR(255)",
+ is_dttm=False,
+ expression="'{{ current_username() }}'",
+ ),
+ ],
+ metrics=[
+ SqlMetric(
+ metric_name="param_test",
+ expression="{{ url_param('multiplier') }} * 1.4",
+ )
+ ],
+ sql="SELECT {{ current_user_id() }} as my_user_id",
+ )
+ db.session.add(dataset)
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ admin = self.get_user(ADMIN_USERNAME)
+ uri = (
+ f"api/v1/dataset/{dataset.id}?"
+
"q=(columns:!(id,sql,columns.column_name,columns.expression,metrics.metric_name,metrics.expression))"
+ "&include_rendered_sql=true&multiplier=4"
+ )
+ rv = self.get_assert_metric(uri, "get")
+ assert rv.status_code == 200
+ response = json.loads(rv.data.decode("utf-8"))
+
+ assert response["result"] == {
+ "id": dataset.id,
+ "sql": "SELECT {{ current_user_id() }} as my_user_id",
+ "rendered_sql": f"SELECT {admin.id} as my_user_id",
+ "columns": [
+ {
+ "column_name": "my_user_id",
+ "expression": None,
+ },
+ {
+ "column_name": "calculated_test",
+ "expression": "'{{ current_username() }}'",
+ "rendered_expression": f"'{admin.username}'",
+ },
+ ],
+ "metrics": [
+ {
+ "metric_name": "param_test",
+ "expression": "{{ url_param('multiplier') }} * 1.4",
+ "rendered_expression": "4 * 1.4",
+ },
+ ],
+ }
+
+ db.session.delete(dataset)
+ db.session.commit()
+
+ def test_get_dataset_render_jinja_exceptions(self):
+ """
+ Dataset API: Test get dataset with the render parameter
+ when rendering raises an exception.
+ """
+ database = get_example_database()
+ dataset = SqlaTable(
+ table_name="test_sql_table_with_incorrect_jinja",
+ database=database,
+ schema=get_example_default_schema(),
+ main_dttm_col="default_dttm",
+ columns=[
+ TableColumn(
+ column_name="my_user_id",
+ type="INTEGER",
+ is_dttm=False,
+ ),
+ TableColumn(
+ column_name="calculated_test",
+ type="VARCHAR(255)",
+ is_dttm=False,
+ expression="'{{ current_username() }'",
+ ),
+ ],
+ metrics=[
+ SqlMetric(
+ metric_name="param_test",
+ expression="{{ url_param('multiplier') } * 1.4",
+ )
+ ],
+ sql="SELECT {{ current_user_id() } as my_user_id",
+ )
+ db.session.add(dataset)
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+
+ uri =
f"api/v1/dataset/{dataset.id}?q=(columns:!(id,sql))&include_rendered_sql=true"
+ rv = self.get_assert_metric(uri, "get")
+ assert rv.status_code == 400
+ response = json.loads(rv.data.decode("utf-8"))
+ assert response["message"] == "Unable to render expression from
dataset query."
+
+ uri = (
+ f"api/v1/dataset/{dataset.id}?q=(columns:!(id,metrics.expression))"
+ "&include_rendered_sql=true&multiplier=4"
+ )
+ rv = self.get_assert_metric(uri, "get")
+ assert rv.status_code == 400
+ response = json.loads(rv.data.decode("utf-8"))
+ assert response["message"] == "Unable to render expression from
dataset metric."
+
+ uri = (
+ f"api/v1/dataset/{dataset.id}?q=(columns:!(id,columns.expression))"
+ "&include_rendered_sql=true"
+ )
+ rv = self.get_assert_metric(uri, "get")
+ assert rv.status_code == 400
+ response = json.loads(rv.data.decode("utf-8"))
+ assert (
+ response["message"]
+ == "Unable to render expression from dataset calculated column."
+ )
+
+ db.session.delete(dataset)
+ db.session.commit()
+
def test_get_dataset_distinct_schema(self):
"""
Dataset API: Test get dataset distinct schema