Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dataset API): Add parameter to optionally render Jinja macros in API response #30721

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion superset/datasets/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@
import logging
from datetime import datetime
from io import BytesIO
from typing import Any
from typing import Any, Optional
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hint: you can do from __future__ import annotations and then use a | None instead of Optional[a].

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 marshmallow import ValidationError
Expand Down Expand Up @@ -65,6 +70,7 @@
GetOrCreateDatasetSchema,
openapi_spec_methods_override,
)
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
Expand Down Expand Up @@ -1056,3 +1062,123 @@ def warm_up_cache(self) -> Response:
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
@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: render
description: Should Jinja macros from sql, metrics and columns be rendered
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: Optional[SqlaTable] = 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)
Comment on lines +1120 to +1145
Copy link
Contributor Author

@Vitor-Avila Vitor-Avila Oct 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is FAB's default implementation for get_headless - ref.

I decided to not simply do self.get_headless() and then modify the response for two reasons:

  • get_headless returns a response object, so while we could just modify the response.json it feels a little hacky.
  • Superset's get_headless implementation includes a call to send_stats_metrics (ref), which wouldn't include the full duration of the API call as the response would be further modified by this method before concluding.

I also thought about just overwriting FAB's pre_get method which is available in case the response needs to be modified as part of get_headless, but we need the Database associated with this dataset, and if the user chooses to not include the database.id in the response then pre_get wouldn't have this info.


if parse_boolean_string(request.args.get("render")):
processor = get_template_processor(database=item.database)
response["result"] = self.render_dataset_fields(
response["result"], processor
)

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, "expression": processor.process_template(item["expression"])}
if item.get("expression")
else item
for item in item_list
]

if metrics := data.get("metrics"):
data["metrics"] = render_item_list(metrics)

if columns := data.get("columns"):
data["columns"] = render_item_list(columns)

if sql := data.get("sql"):
data["sql"] = processor.process_template(sql)

return data
1 change: 0 additions & 1 deletion superset/datasets/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
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",
Expand Down
70 changes: 70 additions & 0 deletions tests/integration_tests/datasets/api_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,76 @@ def test_get_dataset_item(self):
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))"
"&render=true&multiplier=4"
)
rv = self.get_assert_metric(uri, "get")
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
print(response)

assert response["result"] == {
"id": dataset.id,
"sql": f"SELECT {admin.id} as my_user_id",
"columns": [
{
"column_name": "my_user_id",
"expression": None,
},
{
"column_name": "calculated_test",
"expression": f"'{admin.username}'",
},
],
"metrics": [
{
"metric_name": "param_test",
"expression": "4 * 1.4",
},
],
}

db.session.delete(dataset)
db.session.commit()

def test_get_dataset_distinct_schema(self):
"""
Dataset API: Test get dataset distinct schema
Expand Down
Loading