GitHub user Dhruvin4530 added a comment to the discussion: Superset is not able 
to load the result from hydrolix

@dosu

This is my query mutator script.

```
"""Superset configurations, including extra Jinja macros and SQL_QUERY_MUTATOR

Updated for Superset v5.0 compatibility:
- SQL_QUERY_MUTATOR signature changed to (sql: str, **kwargs) -> str
- DatasetDAO moved from superset.datasets.dao to superset.daos.dataset
- get_username() removed; use Flask g.user instead
- merge_extra_filters / convert_legacy_filters_into_adhoc removed (legacy 
filters dropped in v4)
"""

import json
import re
import shlex
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional, Tuple

import logging

log = logging.getLogger(__name__)


def _get_username() -> Optional[str]:
    """Get the current username from Flask's g object.

    Replaces the removed superset.utils.core.get_username().
    """
    try:
        from flask import g

        if hasattr(g, "user") and g.user and hasattr(g.user, "username"):
            return g.user.username
    except Exception:
        pass
    return None


##############################  Jinja Macros  ##############################
def events_dataset_id() -> int:
    """Returns the ID of the events dataset in Superset.

    Potentially needs to have a different value between Superset instances.
    """
    return 2


def dataset_columns(dataset_id: Optional[int] = None) -> List:
    """List of columns available for a dataset, according to Superset.

    Example usage:
        ```
        SELECT
        name
        {% for column in dataset_columns(10) %}
        , "column"
        {% endfor %}
        ```

    Args:
        dataset_id: ID of the dataset in Superset.
    """
    # pylint: disable=import-outside-toplevel
    # v5: DatasetDAO moved from superset.datasets.dao to superset.daos.dataset
    from superset.daos.dataset import DatasetDAO

    if dataset_id is None:
        dataset_id = datasource_id()

    if dataset_id is None:
        return []

    dataset = DatasetDAO.find_by_id(dataset_id)
    if not dataset:
        from superset.datasets.commands.exceptions import DatasetNotFoundError

        raise DatasetNotFoundError(f"Dataset {dataset_id} not found!")

    return dataset.columns


def events_column_names() -> List[str]:
    """List of (non-calculated) colummns available in the "events" table, 
according to Superset.

    Note that for this to work properly, events_dataset_id() needs to return 
the correct dataset ID for the events table
    in this particular Superset instance.
    """
    return [
        col.column_name
        for col in dataset_columns(events_dataset_id())
        if not col.expression
    ]


def dimensions(dataset_id: Optional[int] = None) -> List[str]:
    """List of (non-calculated) dimensions (columns available for GROUP BY) 
available for a dataset, according to Superset.

    Args:
        dataset_id: ID of the dataset in Superset.
    """
    return [
        col.column_name
        for col in dataset_columns(dataset_id)
        if not col.expression and col.groupby
    ]


def slice_id() -> Optional[int]:
    """ID of the "slice" (a.k.a. a "Chart") in Superset associated with the 
current query, if any."""
    # pylint: disable=import-outside-toplevel
    from superset.views.utils import get_form_data

    try:
        return get_form_data()[0].get("slice_id")
    except Exception:
        return None


def datasource_id() -> Optional[int]:
    """Returns the ID of the "datasource" (source dataset) in Superset 
associated with the current query, if any."""
    # pylint: disable=import-outside-toplevel
    from superset.views.utils import get_form_data

    try:
        form_data, slc = get_form_data(use_slice_data=True)
    except Exception:
        return None

    dataset_id = form_data.get("url_params", {}).get("dataset_id")
    if dataset_id is not None:
        return dataset_id

    if slc is not None:
        return slc.datasource_id

    return None


def dashboard_id() -> Optional[int]:
    """Returns the ID of the dashboard in Superset associated with the current 
query, if any."""
    import flask

    try:
        return flask.request.values.get("dashboard_id")
    except Exception:
        return None


def full_form_data() -> Dict:
    """Returns the full form data for the current query.

    This includes various settings passed on from the Superset UI, e.g. filter 
configurations,
    which are used internally in Superset as part of automatic query generation.

    Note: In Superset v4+, legacy filters were removed. The calls to
    convert_legacy_filters_into_adhoc() and merge_extra_filters() have been
    removed as they no longer exist.
    """
    # pylint: disable=import-outside-toplevel
    from superset.views.utils import get_form_data

    form_data, _ = get_form_data()
    # v5: These two functions were removed along with legacy filter support:
    #   convert_legacy_filters_into_adhoc(form_data)
    #   merge_extra_filters(form_data)
    return form_data


def time_filter(
    field_name: str = "timestamp",
    default_minutes_since: Optional[int] = 60,
    default_minutes_until: Optional[int] = 0,
) -> str:
    """Returns an SQL-formatted time filter condition for the selected time 
range.

    This makes it more convenient to write more efficient inner queries in 
virtual datasets.

    Example usage:
        ```
        WHERE {# time_filter("s_timestamp", 10) #}
        AND x = 7
        ```

    Args:
        field_name: field to filter on
        default_minutes_since: default minutes (back from now) to cover, if no 
time range is specified via the Superset UI.
            Set this to None to allow omitting a timerange start.
        default_minutes_until: default minutes (forward from now) to cover, if 
no time range is specified via the Superset UI.
            Set this to None to allow omitting a timerange start.
    """
    since, until = since_until()
    time_format = "%Y-%m-%d %H:%M:%S"

    now = datetime.now(tz=timezone.utc)
    if since is None and default_minutes_since is not None:
        since = now - timedelta(minutes=default_minutes_since)
    if until is None and default_minutes_until is not None:
        until = now + timedelta(minutes=default_minutes_until)

    conditions = []
    if since is not None:
        conditions.append(f"{field_name} >= '{since.strftime(time_format)}'")
    if until is not None:
        conditions.append(f"{field_name} < '{until.strftime(time_format)}'")
    if not conditions:
        conditions.append("1")

    return combine_sql_conditions(conditions)


def where_filters(
    restricted_columns: Optional[List[str]] = None, include_extras: bool = True
) -> str:
    """Returns an SQL-formatted combination of WHERE conditions for the current 
query.

    This makes it more convenient to write more efficient inner queries in 
virtual datasets.

    Example usage:
        ```
        WHERE {# time_filter("timestamp", 30) #}
        AND {# where_filters(events_column_names(), False) #}
        ```

    Args:
        restricted_columns: list of fields to allow (only for "Simple" 
filters); when present, filters that reference unlisted fields
            will be excluded. This is useful for excluding e.g. filters on 
computed fields that are only present in the outer query.
        include_extras: whether to include any extra filters specified via 
"Custom SQL" in Superset - these can't be filtered using
            restricted_columns because Superset doesn't parse field names out 
of them.
    """
    form_data = full_form_data()
    filter_conditions = []

    if include_extras:
        base_condition = form_data.get("extras", {}).get("where")
        if base_condition:
            filter_conditions.append(base_condition)

    for f in form_data.get("adhoc_filters", []):
        if f["clause"] != "WHERE":
            continue
        if restricted_columns is not None and f["subject"] not in 
restricted_columns:
            continue
        filter_conditions.append(adhoc_filter_to_sql(f))

    if not filter_conditions:
        filter_conditions = ["1"]

    return combine_sql_conditions(filter_conditions, joiner="\nAND ")


JINJA_CONTEXT_ADDONS = {
    "events_dataset_id": events_dataset_id,
    "dataset_columns": dataset_columns,
    "events_column_names": events_column_names,
    "dimensions": dimensions,
    "slice_id": slice_id,
    "datasource_id": datasource_id,
    "dashboard_id": dashboard_id,
    "full_form_data": full_form_data,
    "time_filter": time_filter,
    "where_filters": where_filters,
}
"""See the official documentation here:
https://superset.apache.org/docs/installation/sql-templating/#jinja-templates
"""


############################  Jinja Macro Helpers  ############################
def adhoc_filter_to_sql(filter: Dict) -> str:
    col = filter.get("subject")
    op = filter.get("operator")
    val = filter.get("comparator")
    if val is None:
        return f"{col} {op}"
    else:
        return f"{col} {op} {val!r}"


def combine_sql_conditions(conditions: List[str], joiner: str = " AND ") -> str:
    return "(" + joiner.join(f"({c})" for c in conditions) + ")"


def since_until() -> Tuple[Optional[datetime], Optional[datetime]]:
    # pylint: disable=import-outside-toplevel
    from superset.utils.date_parser import get_since_until

    form_data = full_form_data()
    time_range = form_data.get("time_range")
    if time_range is not None:
        return get_since_until(time_range)
    else:
        return None, None


###########################  Query Mutator Helpers  ###########################
def load_setting(token: str) -> Any:
    if token[0] == "'":
        token = '"' + token[1:-1] + '"'
    return json.loads(token)


def dump_setting(value: Any) -> str:
    token = json.dumps(value)
    if token[0] == '"':
        token = "'" + token[1:-1] + "'"
    return token


def split_settings(settings_str: str) -> Dict[str, Any]:
    """Parses the provided settings string into a dictionary of settings 
key:value pairs"""
    result = {}
    tokens = list(shlex.shlex(settings_str))
    it = iter(tokens)
    try:
        while True:
            key = next(it)
            if next(it) != "=":
                break
            result[key] = load_setting(next(it))
            if next(it) != ",":
                break
    except StopIteration:
        pass
    return result


def join_settings(settings_dict: Dict[str, Any]) -> str:
    """Joins settings using function `dump_settings()`"""
    return ", ".join(
        f"{key}={dump_setting(value)}" for key, value in settings_dict.items()
    )


def extract_settings(query: str) -> Tuple[str, Dict[str, Any]]:
    """Extracts currently applied query settings from the query string.

    Args:
        query (str): The query string to extract from.

    Returns:
        Tuple[str, Dict[str, Any]]: A tuple containing the query and a 
dictionary of
        the extracted query `SETTINGS`.
    """
    settings_regex = r"SETTINGS\s+(hdx_[^\)\n]+)"
    flags = re.IGNORECASE | re.MULTILINE
    results = re.findall(settings_regex, query, flags=flags)
    current_settings = {}

    if results:
        query = re.sub(settings_regex, "", query, flags=flags)
        for result in results:
            current_settings.update(split_settings(result))

    return query, current_settings


def hdx_with_settings(query: str, settings: Dict[str, Any]) -> str:
    """Applies provided Hdyrolix query SETTINGS to the query string

    https://docs.hydrolix.io/docs/query-api-http-options#using-query-options
    """
    settings_str = join_settings(settings)
    return f"{query}\nSETTINGS {settings_str}"


def athena_with_settings(query: str, settings: Dict[str, Any]) -> str:
    """This is just to give some tagging to the query in the Athena log.

    This functionality to be improved in JIRA ticket 
[DATA-2357](https://arkoselabs.atlassian.net/browse/DATA-2357)
    """
    settings_str = join_settings(settings)
    return f"/*\nSuperset {settings_str}\n*/\n{query}"


def replace_series_limit(sql: str) -> str:
    if "_no_replace_series_limit" in sql:
        return sql

    inner_query_regex = (
        r"INNER JOIN.*?\(SELECT.*?LIMIT ([0-9]+)\) AS [`]?\w+[`]?.*?(?=WHERE)"
    )
    flags = re.IGNORECASE | re.DOTALL
    match = re.search(inner_query_regex, sql, flags=flags)
    if not match:
        return sql

    series_limit = int(match.group(1))
    sql = re.sub(inner_query_regex, "", sql, flags=flags)

    # Extract the first column from GROUP BY to use in LIMIT BY
    # v4 used __timestamp, v5 uses hashed aliases like timestamp_d7e6d5
    limit_by_col = "__timestamp"
    group_by_match = re.search(r"GROUP BY\s+`?(\w+)`?", sql, 
flags=re.IGNORECASE)
    if group_by_match:
        limit_by_col = group_by_match.group(1)

    limit_regex = "LIMIT [0-9]+$"
    sql = re.sub(limit_regex, "", sql, flags=flags)
    sql = f"{sql}\nLIMIT {series_limit} BY {limit_by_col}"
    return sql


def rewrite_array_join_filter_conditions(sql: str) -> str:
    if "_no_array_join_rewrite" in sql:
        return sql

    flags = re.MULTILINE

    array_join_equals_regex = (
        r"(\n[ \t]*| +)AND arrayJoin\(([^\n]+?)\) = ([^\n]*?)(?=[ \t]*(?:\n|$))"
    )
    array_join_equals_replacement = r"\1AND has(\2, \3)"
    sql = re.sub(
        array_join_equals_regex, array_join_equals_replacement, sql, flags=flags
    )

    # Special case for single element + IN clause
    array_join_one_element_in_regex = (
        r"(\n[ \t]*| +)AND arrayJoin\(([^\n]+?)\) IN \(([^,\n]*?)\)"
    )
    array_join_one_element_in_replacement = r"\1AND has(\2, \3)"
    sql = re.sub(
        array_join_one_element_in_regex,
        array_join_one_element_in_replacement,
        sql,
        flags=flags,
    )

    # Alias with _AND -> use hasAll(...)
    array_join_in_AND_regex = (
        r"(\n[ \t]*| +)AND \(arrayJoin\(([^\n]+?)\) AS __\w*_AND\) IN 
\(([^)]*?)\)"
    )
    array_join_in_AND_replacement = r"\1AND hasAll(\2, [\3])"
    sql = re.sub(
        array_join_in_AND_regex, array_join_in_AND_replacement, sql, flags=flags
    )

    # Alias with _OR -> use hasAny(...)
    array_join_in_OR_regex = (
        r"(\n[ \t]*| +)AND \(arrayJoin\(([^\n]+?)\) AS __\w*_OR\) IN 
\(([^)]*?)\)"
    )
    array_join_in_OR_replacement = r"\1AND hasAny(\2, [\3])"
    sql = re.sub(array_join_in_OR_regex, array_join_in_OR_replacement, sql, 
flags=flags)

    # Any other alias => won't be replaced (Like _no_array_join_rewrite, but 
for one field instead of the whole query)

    # Default (no alias) -> use hasAny(...)
    array_join_in_regex = r"(\n[ \t]*| +)AND arrayJoin\(([^\n]+?)\) IN 
\(([^)]*?)\)"
    array_join_in_replacement = r"\1AND hasAny(\2, [\3])"
    sql = re.sub(array_join_in_regex, array_join_in_replacement, sql, 
flags=flags)

    return sql


def get_dashboard_certification(dashboard_id: int) -> bool:
    # from superset import db
    from superset.models.dashboard import Dashboard

    CERTIFIED_DASHBOARD_NAMES = [""]

    results = Dashboard.query.filter_by(id=dashboard_id).first()
    # session = db.session
    # result = session.execute(select(Dashboard).order_by(User.id))

    if results.dashboard_title in CERTIFIED_DASHBOARD_NAMES:
        return True
    return results is not None


def get_settings(
    user_name: str,
    security_manager: Any,
    query_settings: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    SECONDS_PER_DAY = 60 * 60 * 24
    BINARY_GIGA = 2**30

    # Mutable settings can be changed on a per-query basis by using a SETTINGS 
statement in the query.
    # query_settings can then override any mutable default settings for that 
user.
    mutable_settings = set(
        [
            "hdx_query_timerange_required",
            "hdx_query_max_timerange_sec",
            "hdx_query_max_execution_time",
            "hdx_query_pool_name",
        ]
    )

    # Default settings
    settings = {
        "hdx_query_pool_name": "query-peer",
        "hdx_query_max_memory_usage": 38 * BINARY_GIGA,
        "hdx_query_timerange_required": True,
        "hdx_query_max_timerange_sec": 62 * SECONDS_PER_DAY,
        "hdx_query_max_execution_time": 120,
    }

    # Role overrides are processed in order, and will override values in the 
default settings.
    role_overrides = [
        (
            "SOC",
            {
                "hdx_query_pool_name": "soc",
                "hdx_query_max_timerange_sec": 93 * SECONDS_PER_DAY,
            },
        ),
        (
            "CSOPS",
            {
                "hdx_query_pool_name": "soc",
                "hdx_query_max_timerange_sec": 93 * SECONDS_PER_DAY,
            },
        ),
        (
            "SRSOC",
            {
                "hdx_query_pool_name": "soc",
                "hdx_query_max_timerange_sec": 93 * SECONDS_PER_DAY,
            },
        ),
        (
            "PORTAL",
            {
                "hdx_query_pool_name": "portal",
            },
        ),
    ]

    # Apply role overrides to the default settings, based on the user's roles.
    user = security_manager.find_user(username=user_name)
    log.debug(f"user: {user}")
    user_roles = security_manager.get_user_roles(user)
    user_role_names = set(user_role.name for user_role in user_roles)
    for role_name, override_dict in role_overrides:
        if role_name in user_role_names:
            settings.update(override_dict)

    # Update any mutable / absent settings with the query settings, if present.
    if query_settings is not None:
        for key, value in query_settings.items():
            if key in mutable_settings or key not in settings:
                settings[key] = value

    # Add an admin comment to the SETTINGS
    admin_comment = f"User: {user}"
    s_id = slice_id()
    if s_id is not None:
        admin_comment += f"; slice_id: {s_id}"
    ds_id = datasource_id()
    if ds_id is not None:
        admin_comment += f"; datasource_id: {ds_id}"
    d_id = dashboard_id()
    if d_id is not None:
        admin_comment += f"; dashboard_id: {d_id}"
    settings["hdx_query_admin_comment"] = admin_comment

    return settings


###############################  Query Mutator  ###############################
def SQL_QUERY_MUTATOR(sql, **kwargs):
    """Query mutator compatible with Superset v5.0+.

    In Superset v2, the signature was:
        SQL_QUERY_MUTATOR(sql, security_manager, database, **kwargs)

    Since Superset v2.0 (PR #19083), the signature changed to:
        SQL_QUERY_MUTATOR(sql: str, **kwargs: Any) -> str

    where security_manager, database, user_name, etc. are passed via kwargs.
    """
    # Extract arguments from kwargs (v5 passes everything via kwargs)
    security_manager = kwargs.get("security_manager")
    database = kwargs.get("database")

    # Sometimes we get a different object here.
    # But it should at least be convertible to a string that represents the 
actual query.
    log.debug(f"SQL_QUERY_MUTATOR")
    log.debug(f"kwargs: {kwargs}")
    query = str(sql)

    # Don't mutate if this isn't a SELECT query
    select_search = re.search(r"^\s*(SELECT|WITH)", query, flags=re.IGNORECASE)
    if not select_search:
        return query

    # Extract any SETTINGS already set in the query
    query, query_settings = extract_settings(query)

    # Get username - v5: get_username() was removed, use Flask g.user instead
    user_name = _get_username()

    # Determine the SETTINGS for this query
    new_settings = get_settings(
        user_name=user_name,
        security_manager=security_manager,
        query_settings=query_settings,
    )
    database_name = database.database_name
    log.debug(f"database_name: {database_name}")
    # Add the SETTINGS to the query and return it.
    if "hydrolix" in database_name.lower():
        # Fix series limits to use LIMIT BY instead of subquery.
        query = replace_series_limit(query)
        query = rewrite_array_join_filter_conditions(query)
        query = hdx_with_settings(query, new_settings)
    if "athena" in database_name.lower():
        query = athena_with_settings(query, new_settings)
    return query
    ```


GitHub link: 
https://github.com/apache/superset/discussions/41395#discussioncomment-17424927

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: 
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to