mirror of
https://github.com/apache/superset.git
synced 2026-07-18 12:45:44 +00:00
1520 lines
48 KiB
Python
1520 lines
48 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
# pylint: disable=import-outside-toplevel
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from typing import Any, TYPE_CHECKING, Union
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from pytest_mock import MockerFixture
|
|
from sqlalchemy import create_engine, text
|
|
from sqlalchemy.orm.session import Session
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
if TYPE_CHECKING:
|
|
from superset.models.core import Database
|
|
|
|
|
|
@pytest.fixture
|
|
def database(mocker: MockerFixture, session: Session) -> Database:
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.models.core import Database
|
|
|
|
SqlaTable.metadata.create_all(session.get_bind())
|
|
|
|
engine = create_engine(
|
|
"sqlite://",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
|
|
|
|
connection = engine.raw_connection()
|
|
connection.execute("CREATE TABLE t (a INTEGER, b TEXT)")
|
|
connection.execute("INSERT INTO t VALUES (1, 'Alice')")
|
|
connection.execute("INSERT INTO t VALUES (NULL, 'Bob')")
|
|
connection.commit()
|
|
|
|
# since we're using an in-memory SQLite database, make sure we always
|
|
# return the same engine where the table was created
|
|
@contextmanager
|
|
def mock_get_sqla_engine():
|
|
yield engine
|
|
|
|
mocker.patch.object(
|
|
database,
|
|
"get_sqla_engine",
|
|
new=mock_get_sqla_engine,
|
|
)
|
|
|
|
return database
|
|
|
|
|
|
def test_values_for_column(database: Database) -> None:
|
|
"""
|
|
Test the `values_for_column` method.
|
|
|
|
NULL values should be returned as `None`, not `np.nan`, since NaN cannot be
|
|
serialized to JSON.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="t",
|
|
columns=[TableColumn(column_name="a")],
|
|
)
|
|
assert table.values_for_column("a") == [1, None]
|
|
|
|
|
|
def test_values_for_column_with_rls(database: Database) -> None:
|
|
"""
|
|
Test the `values_for_column` method with RLS enabled.
|
|
"""
|
|
from sqlalchemy.sql.elements import TextClause
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="t",
|
|
columns=[
|
|
TableColumn(column_name="a"),
|
|
],
|
|
)
|
|
with patch.object(
|
|
table,
|
|
"get_sqla_row_level_filters",
|
|
return_value=[
|
|
TextClause("a = 1"),
|
|
],
|
|
):
|
|
assert table.values_for_column("a") == [1]
|
|
|
|
|
|
def test_values_for_column_with_rls_no_values(database: Database) -> None:
|
|
"""
|
|
Test the `values_for_column` method with RLS enabled and no values.
|
|
"""
|
|
from sqlalchemy.sql.elements import TextClause
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="t",
|
|
columns=[
|
|
TableColumn(column_name="a"),
|
|
],
|
|
)
|
|
with patch.object(
|
|
table,
|
|
"get_sqla_row_level_filters",
|
|
return_value=[
|
|
TextClause("a = 2"),
|
|
],
|
|
):
|
|
assert table.values_for_column("a") == []
|
|
|
|
|
|
def test_values_for_column_calculated(
|
|
mocker: MockerFixture,
|
|
database: Database,
|
|
) -> None:
|
|
"""
|
|
Test that calculated columns work.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="t",
|
|
columns=[
|
|
TableColumn(
|
|
column_name="starts_with_A",
|
|
expression="CASE WHEN b LIKE 'A%' THEN 'yes' ELSE 'nope' END",
|
|
)
|
|
],
|
|
)
|
|
assert table.values_for_column("starts_with_A") == ["yes", "nope"]
|
|
|
|
|
|
def test_values_for_column_double_percents(
|
|
mocker: MockerFixture,
|
|
database: Database,
|
|
) -> None:
|
|
"""
|
|
Test the behavior of `double_percents`.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
with database.get_sqla_engine() as engine:
|
|
engine.dialect.identifier_preparer._double_percents = "pyformat"
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="t",
|
|
columns=[
|
|
TableColumn(
|
|
column_name="starts_with_A",
|
|
expression="CASE WHEN b LIKE 'A%' THEN 'yes' ELSE 'nope' END",
|
|
)
|
|
],
|
|
)
|
|
|
|
mutate_sql_based_on_config = mocker.patch.object(
|
|
database,
|
|
"mutate_sql_based_on_config",
|
|
side_effect=lambda sql: sql,
|
|
)
|
|
pd = mocker.patch("superset.models.helpers.pd")
|
|
|
|
table.values_for_column("starts_with_A")
|
|
|
|
# make sure the SQL originally had double percents
|
|
mutate_sql_based_on_config.assert_called_with(
|
|
"SELECT DISTINCT CASE WHEN b LIKE 'A%%' THEN 'yes' ELSE 'nope' END "
|
|
"AS column_values \nFROM t\n LIMIT 10000 OFFSET 0"
|
|
)
|
|
# make sure final query has single percents
|
|
with database.get_sqla_engine() as engine:
|
|
expected_sql = text(
|
|
"SELECT DISTINCT CASE WHEN b LIKE 'A%' THEN 'yes' ELSE 'nope' END "
|
|
"AS column_values \nFROM t\n LIMIT 10000 OFFSET 0"
|
|
)
|
|
called_sql = pd.read_sql_query.call_args.kwargs["sql"]
|
|
called_conn = pd.read_sql_query.call_args.kwargs["con"]
|
|
|
|
assert called_sql.compare(expected_sql) is True
|
|
assert called_conn.engine == engine
|
|
|
|
|
|
def test_apply_series_others_grouping(database: Database) -> None:
|
|
"""
|
|
Test the `_apply_series_others_grouping` method.
|
|
|
|
This method should replace series columns with CASE expressions that
|
|
group remaining series into an "Others" category based on a condition.
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
# Create a mock table for testing
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[
|
|
TableColumn(column_name="category", type="TEXT"),
|
|
TableColumn(column_name="metric_col", type="INTEGER"),
|
|
TableColumn(column_name="other_col", type="TEXT"),
|
|
],
|
|
)
|
|
|
|
# Mock SELECT expressions
|
|
category_expr = Mock()
|
|
category_expr.name = "category"
|
|
metric_expr = Mock()
|
|
metric_expr.name = "metric_col"
|
|
other_expr = Mock()
|
|
other_expr.name = "other_col"
|
|
|
|
select_exprs = [category_expr, metric_expr, other_expr]
|
|
|
|
# Mock GROUP BY columns
|
|
groupby_all_columns = {
|
|
"category": category_expr,
|
|
"other_col": other_expr,
|
|
}
|
|
|
|
# Define series columns (only category should be modified)
|
|
groupby_series_columns = {"category": category_expr}
|
|
|
|
# Create a condition factory that always returns True
|
|
def always_true_condition(col_name: str, expr) -> bool:
|
|
return True
|
|
|
|
# Mock the make_sqla_column_compatible method
|
|
def mock_make_compatible(expr, name=None):
|
|
mock_result = Mock()
|
|
mock_result.name = name
|
|
return mock_result
|
|
|
|
with patch.object(
|
|
table, "make_sqla_column_compatible", side_effect=mock_make_compatible
|
|
):
|
|
# Call the method
|
|
result_select_exprs, result_groupby_columns = (
|
|
table._apply_series_others_grouping(
|
|
select_exprs,
|
|
groupby_all_columns,
|
|
groupby_series_columns,
|
|
always_true_condition,
|
|
)
|
|
)
|
|
|
|
# Verify SELECT expressions
|
|
assert len(result_select_exprs) == 3
|
|
|
|
# Category (series column) should be replaced with CASE expression
|
|
category_result = result_select_exprs[0]
|
|
assert category_result.name == "category" # Should be made compatible
|
|
|
|
# Metric (non-series column) should remain unchanged
|
|
assert result_select_exprs[1] == metric_expr
|
|
|
|
# Other (non-series column) should remain unchanged
|
|
assert result_select_exprs[2] == other_expr
|
|
|
|
# Verify GROUP BY columns
|
|
assert len(result_groupby_columns) == 2
|
|
|
|
# Category (series column) should be replaced with CASE expression
|
|
assert "category" in result_groupby_columns
|
|
category_groupby_result = result_groupby_columns["category"]
|
|
# After our fix, GROUP BY expressions are NOT wrapped with
|
|
# make_sqla_column_compatible, so it should be a raw CASE expression,
|
|
# not a Mock with .name attribute. Verify it's different from the original
|
|
assert category_groupby_result != category_expr
|
|
|
|
# Other (non-series column) should remain unchanged
|
|
assert result_groupby_columns["other_col"] == other_expr
|
|
|
|
|
|
def test_apply_series_others_grouping_with_false_condition(database: Database) -> None:
|
|
"""
|
|
Test the `_apply_series_others_grouping` method with a condition that returns False.
|
|
|
|
This should result in CASE expressions that always use "Others".
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
# Create a mock table for testing
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[TableColumn(column_name="category", type="TEXT")],
|
|
)
|
|
|
|
# Mock SELECT expressions
|
|
category_expr = Mock()
|
|
category_expr.name = "category"
|
|
select_exprs = [category_expr]
|
|
|
|
# Mock GROUP BY columns
|
|
groupby_all_columns = {"category": category_expr}
|
|
groupby_series_columns = {"category": category_expr}
|
|
|
|
# Create a condition factory that always returns False
|
|
def always_false_condition(col_name: str, expr) -> bool:
|
|
return False
|
|
|
|
# Mock the make_sqla_column_compatible method
|
|
def mock_make_compatible(expr, name=None):
|
|
mock_result = Mock()
|
|
mock_result.name = name
|
|
return mock_result
|
|
|
|
with patch.object(
|
|
table, "make_sqla_column_compatible", side_effect=mock_make_compatible
|
|
):
|
|
# Call the method
|
|
result_select_exprs, result_groupby_columns = (
|
|
table._apply_series_others_grouping(
|
|
select_exprs,
|
|
groupby_all_columns,
|
|
groupby_series_columns,
|
|
always_false_condition,
|
|
)
|
|
)
|
|
|
|
# Verify that the expressions were replaced (we can't test SQL generation
|
|
# in a unit test, but we can verify the structure changed)
|
|
assert len(result_select_exprs) == 1
|
|
assert result_select_exprs[0].name == "category"
|
|
|
|
assert len(result_groupby_columns) == 1
|
|
assert "category" in result_groupby_columns
|
|
# GROUP BY expression should be a CASE expression, not the original
|
|
assert result_groupby_columns["category"] != category_expr
|
|
|
|
|
|
def test_apply_series_others_grouping_sql_compilation(database: Database) -> None:
|
|
"""
|
|
Test that the `_apply_series_others_grouping` method properly quotes
|
|
the 'Others' literal in both SELECT and GROUP BY clauses.
|
|
|
|
This test verifies the fix for the bug where 'Others' was not quoted
|
|
in the GROUP BY clause, causing SQL syntax errors.
|
|
"""
|
|
import sqlalchemy as sa
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
# Create a real table instance
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[
|
|
TableColumn(column_name="name", type="TEXT"),
|
|
TableColumn(column_name="value", type="INTEGER"),
|
|
],
|
|
)
|
|
|
|
# Create real SQLAlchemy expressions
|
|
name_col = sa.column("name")
|
|
value_col = sa.column("value")
|
|
|
|
select_exprs = [name_col, value_col]
|
|
groupby_all_columns = {"name": name_col}
|
|
groupby_series_columns = {"name": name_col}
|
|
|
|
# Condition factory that checks if a subquery column is not null
|
|
def condition_factory(col_name: str, expr):
|
|
return sa.column("series_limit.name__").is_not(None)
|
|
|
|
# Call the method
|
|
result_select_exprs, result_groupby_columns = table._apply_series_others_grouping(
|
|
select_exprs,
|
|
groupby_all_columns,
|
|
groupby_series_columns,
|
|
condition_factory,
|
|
)
|
|
|
|
# Get the database dialect from the actual database
|
|
with database.get_sqla_engine() as engine:
|
|
dialect = engine.dialect
|
|
|
|
# Test SELECT expression compilation
|
|
select_case_expr = result_select_exprs[0]
|
|
select_sql = str(
|
|
select_case_expr.compile(
|
|
dialect=dialect, compile_kwargs={"literal_binds": True}
|
|
)
|
|
)
|
|
|
|
# Test GROUP BY expression compilation
|
|
groupby_case_expr = result_groupby_columns["name"]
|
|
groupby_sql = str(
|
|
groupby_case_expr.compile(
|
|
dialect=dialect, compile_kwargs={"literal_binds": True}
|
|
)
|
|
)
|
|
|
|
# Different databases may use different quote characters
|
|
# PostgreSQL/MySQL use single quotes, some might use double quotes
|
|
# The key is that Others should be quoted, not bare
|
|
|
|
# Check that 'Others' appears with some form of quotes
|
|
# and not as a bare identifier
|
|
assert " Others " not in select_sql, "Found unquoted 'Others' in SELECT"
|
|
assert " Others " not in groupby_sql, "Found unquoted 'Others' in GROUP BY"
|
|
|
|
# Check for common quoting patterns
|
|
has_single_quotes = "'Others'" in select_sql and "'Others'" in groupby_sql
|
|
has_double_quotes = '"Others"' in select_sql and '"Others"' in groupby_sql
|
|
|
|
assert has_single_quotes or has_double_quotes, (
|
|
"Others literal should be quoted with either single or double quotes"
|
|
)
|
|
|
|
# Verify the structure of the generated SQL
|
|
assert "CASE WHEN" in select_sql
|
|
assert "CASE WHEN" in groupby_sql
|
|
|
|
# Check that ELSE is followed by a quoted value
|
|
assert "ELSE " in select_sql
|
|
assert "ELSE " in groupby_sql
|
|
|
|
# The key test is that GROUP BY expression doesn't have a label
|
|
# while SELECT might or might not have one depending on the database
|
|
# What matters is that GROUP BY should NOT have label
|
|
assert " AS " not in groupby_sql # GROUP BY should NOT have label
|
|
|
|
# Also verify that if SELECT has a label, it's different from GROUP BY
|
|
if " AS " in select_sql:
|
|
# If labeled, SELECT and GROUP BY should be different
|
|
assert select_sql != groupby_sql
|
|
|
|
|
|
def test_apply_series_others_grouping_no_label_in_groupby(database: Database) -> None:
|
|
"""
|
|
Test that GROUP BY expressions don't get wrapped with make_sqla_column_compatible.
|
|
|
|
This is a specific test for the bug fix where make_sqla_column_compatible
|
|
was causing issues with literal quoting in GROUP BY clauses.
|
|
"""
|
|
from unittest.mock import ANY, call, Mock, patch
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
# Create a table instance
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[TableColumn(column_name="category", type="TEXT")],
|
|
)
|
|
|
|
# Mock expressions
|
|
category_expr = Mock()
|
|
category_expr.name = "category"
|
|
|
|
select_exprs = [category_expr]
|
|
groupby_all_columns = {"category": category_expr}
|
|
groupby_series_columns = {"category": category_expr}
|
|
|
|
def condition_factory(col_name: str, expr):
|
|
return True
|
|
|
|
# Track calls to make_sqla_column_compatible
|
|
with patch.object(
|
|
table, "make_sqla_column_compatible", side_effect=lambda expr, name: expr
|
|
) as mock_make_compatible:
|
|
result_select_exprs, result_groupby_columns = (
|
|
table._apply_series_others_grouping(
|
|
select_exprs,
|
|
groupby_all_columns,
|
|
groupby_series_columns,
|
|
condition_factory,
|
|
)
|
|
)
|
|
|
|
# Verify make_sqla_column_compatible was called for SELECT expressions
|
|
# but NOT for GROUP BY expressions
|
|
calls = mock_make_compatible.call_args_list
|
|
|
|
# Should have exactly one call (for the SELECT expression)
|
|
assert len(calls) == 1
|
|
|
|
# The call should be for the SELECT expression with the column name
|
|
# Using unittest.mock.ANY to match any CASE expression
|
|
assert calls[0] == call(ANY, "category")
|
|
|
|
# Verify the GROUP BY expression was NOT passed through
|
|
# make_sqla_column_compatible - it should be the raw CASE expression
|
|
assert "category" in result_groupby_columns
|
|
# The GROUP BY expression should be different from the SELECT expression
|
|
# because only SELECT gets make_sqla_column_compatible applied
|
|
|
|
|
|
def test_build_metric_expression_adhoc(database: Database) -> None:
|
|
"""
|
|
Test the `_build_metric_expression` method with adhoc metrics.
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
from superset.utils.core import AdhocMetricExpressionType
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[
|
|
TableColumn(column_name="sales", type="NUMERIC"),
|
|
TableColumn(column_name="quantity", type="INTEGER"),
|
|
],
|
|
)
|
|
|
|
# Test adhoc metric with SIMPLE expression
|
|
adhoc_metric = {
|
|
"expressionType": AdhocMetricExpressionType.SIMPLE,
|
|
"column": {"column_name": "sales"},
|
|
"aggregate": "SUM",
|
|
"label": "total_sales",
|
|
}
|
|
|
|
# Mock the adhoc_metric_to_sqla method
|
|
expected_result = Mock()
|
|
with patch.object(
|
|
table, "adhoc_metric_to_sqla", return_value=expected_result
|
|
) as mock_adhoc:
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(datasource=table)
|
|
result = table._build_metric_expression(
|
|
adhoc_metric, # type: ignore[arg-type]
|
|
query_obj=query_obj,
|
|
)
|
|
|
|
assert result == expected_result
|
|
mock_adhoc.assert_called_once_with(
|
|
metric=adhoc_metric,
|
|
columns_by_name=query_obj.columns_by_name,
|
|
template_processor=None,
|
|
)
|
|
|
|
|
|
def test_build_metric_expression_named(database: Database) -> None:
|
|
"""
|
|
Test the `_build_metric_expression` method with named metrics.
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
|
|
|
|
# Create a table with a named metric
|
|
metric = SqlMetric(metric_name="avg_price", expression="AVG(price)")
|
|
mock_col = Mock()
|
|
mock_col.name = "avg_price_col"
|
|
metric.get_sqla_col = Mock(return_value=mock_col) # type: ignore[method-assign]
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[TableColumn(column_name="price", type="NUMERIC")],
|
|
metrics=[metric],
|
|
)
|
|
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(datasource=table)
|
|
|
|
result = table._build_metric_expression("avg_price", query_obj)
|
|
|
|
# Should have called get_sqla_col on the metric
|
|
metric.get_sqla_col.assert_called_once_with(template_processor=None)
|
|
assert result == mock_col
|
|
|
|
|
|
def test_build_metric_expression_invalid(database: Database) -> None:
|
|
"""
|
|
Test the `_build_metric_expression` method with invalid metric.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
from superset.exceptions import QueryObjectValidationError
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[TableColumn(column_name="sales", type="NUMERIC")],
|
|
)
|
|
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(datasource=table)
|
|
|
|
# Test with non-existent metric name
|
|
with pytest.raises(QueryObjectValidationError) as exc_info:
|
|
table._build_metric_expression("non_existent_metric", query_obj)
|
|
|
|
assert "Metric 'non_existent_metric' does not exist" in str(exc_info.value)
|
|
|
|
|
|
def test_normalize_column_labels(database: Database) -> None:
|
|
"""
|
|
Test the `_normalize_column_labels` method.
|
|
"""
|
|
from unittest.mock import Mock, patch, PropertyMock
|
|
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
# Mock db_engine_spec
|
|
mock_engine_spec = Mock()
|
|
mock_engine_spec.make_label_compatible = lambda x: f"normalized_{x}"
|
|
|
|
with patch.object(
|
|
type(table), "db_engine_spec", new_callable=PropertyMock
|
|
) as mock_prop:
|
|
mock_prop.return_value = mock_engine_spec
|
|
|
|
# Test with series columns
|
|
series_columns = ["col1", "col2", "col3"]
|
|
result = table._normalize_column_labels(None, series_columns)
|
|
|
|
assert result == ["normalized_col1", "normalized_col2", "normalized_col3"]
|
|
|
|
# Test with no series columns
|
|
result = table._normalize_column_labels(None, None)
|
|
assert result == []
|
|
|
|
# Test with empty series columns
|
|
result = table._normalize_column_labels(None, [])
|
|
assert result == []
|
|
|
|
|
|
def test_wrap_query_for_rowcount(database: Database) -> None:
|
|
"""
|
|
Test the `_wrap_query_for_rowcount` method.
|
|
"""
|
|
from unittest.mock import Mock, patch, PropertyMock
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.exceptions import QueryObjectValidationError
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
# Mock db_engine_spec
|
|
mock_engine_spec = Mock()
|
|
|
|
with patch.object(
|
|
type(table), "db_engine_spec", new_callable=PropertyMock
|
|
) as mock_prop:
|
|
mock_prop.return_value = mock_engine_spec
|
|
# Test when subqueries are allowed
|
|
mock_engine_spec.allows_subqueries = True
|
|
|
|
# Create a mock query
|
|
original_query = sa.select([sa.column("col1"), sa.column("col2")])
|
|
|
|
# Mock make_sqla_column_compatible to return a real SQLAlchemy column
|
|
from sqlalchemy import literal_column
|
|
|
|
mock_col = literal_column("COUNT(*)")
|
|
mock_col.key = "rowcount"
|
|
|
|
with patch.object(
|
|
table, "make_sqla_column_compatible", return_value=mock_col
|
|
) as mock_make:
|
|
wrapped_query, labels_expected = table._wrap_query_for_rowcount(
|
|
original_query
|
|
)
|
|
|
|
# Check that make_sqla_column_compatible was called correctly
|
|
mock_make.assert_called_once()
|
|
call_args = mock_make.call_args[0]
|
|
# First argument should be a literal column with COUNT(*)
|
|
assert str(call_args[0]) == "COUNT(*)"
|
|
assert call_args[1] == "rowcount"
|
|
|
|
# Check labels_expected
|
|
assert labels_expected == ["rowcount"]
|
|
|
|
# Verify the wrapped query structure
|
|
assert wrapped_query is not None
|
|
|
|
# Test when subqueries are not allowed
|
|
mock_engine_spec.allows_subqueries = False
|
|
|
|
with pytest.raises(QueryObjectValidationError) as exc_info:
|
|
table._wrap_query_for_rowcount(original_query)
|
|
|
|
assert "Database does not support subqueries" in str(exc_info.value)
|
|
|
|
|
|
def test_normalize_filter_value(database: Database) -> None:
|
|
"""
|
|
Test the `_normalize_filter_value` method.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.utils.core import FilterOperator, GenericDataType
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
# Test TEMPORAL_RANGE - should pass through unchanged
|
|
result = table._normalize_filter_value(
|
|
"last 7 days", GenericDataType.TEMPORAL, FilterOperator.TEMPORAL_RANGE
|
|
)
|
|
assert result == "last 7 days"
|
|
|
|
# Test numeric string conversion
|
|
result = table._normalize_filter_value(
|
|
"123.45", GenericDataType.NUMERIC, FilterOperator.EQUALS
|
|
)
|
|
assert result == 123.45
|
|
|
|
# Test numeric string with LIKE operator - should not convert
|
|
result = table._normalize_filter_value(
|
|
"123", GenericDataType.NUMERIC, FilterOperator.LIKE
|
|
)
|
|
assert result == "123"
|
|
|
|
# Test NULL_STRING conversion
|
|
result = table._normalize_filter_value(
|
|
"<NULL>", GenericDataType.STRING, FilterOperator.EQUALS
|
|
)
|
|
assert result is None
|
|
|
|
# Test EMPTY_STRING conversion
|
|
result = table._normalize_filter_value(
|
|
"", GenericDataType.STRING, FilterOperator.EQUALS
|
|
)
|
|
assert result == ""
|
|
|
|
# Test boolean conversion
|
|
result = table._normalize_filter_value(
|
|
"true", GenericDataType.BOOLEAN, FilterOperator.EQUALS
|
|
)
|
|
assert result is True
|
|
|
|
result = table._normalize_filter_value(
|
|
"false", GenericDataType.BOOLEAN, FilterOperator.EQUALS
|
|
)
|
|
assert result is False
|
|
|
|
# Test string trimming (only removes \t and \n, not spaces)
|
|
result = table._normalize_filter_value(
|
|
" hello\t\n", GenericDataType.STRING, FilterOperator.EQUALS
|
|
)
|
|
assert result == " hello"
|
|
|
|
|
|
def test_deduplicate_select_columns(database: Database) -> None:
|
|
"""
|
|
Test the `_deduplicate_select_columns` method.
|
|
"""
|
|
from unittest.mock import Mock, patch, PropertyMock
|
|
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
# Mock db_engine_spec
|
|
mock_engine_spec = Mock()
|
|
|
|
# Create mock expressions with proper name attributes
|
|
col1 = Mock()
|
|
col1.name = "col1"
|
|
col2 = Mock()
|
|
col2.name = "col2"
|
|
col3 = Mock()
|
|
col3.name = "col3"
|
|
metric1 = Mock()
|
|
metric1.name = "col2" # Duplicate name with col2
|
|
metric2 = Mock()
|
|
metric2.name = "metric2"
|
|
orderby1 = Mock()
|
|
orderby1.name = "orderby1"
|
|
|
|
select_exprs = [col1, col2, col3]
|
|
metrics_exprs = [metric1, metric2]
|
|
orderby_exprs = [orderby1]
|
|
|
|
with patch.object(
|
|
type(table), "db_engine_spec", new_callable=PropertyMock
|
|
) as mock_prop:
|
|
mock_prop.return_value = mock_engine_spec
|
|
# Test with allows_hidden_orderby_agg = True
|
|
mock_engine_spec.allows_hidden_orderby_agg = True
|
|
|
|
result = table._deduplicate_select_columns(
|
|
select_exprs, metrics_exprs, orderby_exprs
|
|
)
|
|
|
|
# Should have col1, col2 (not metric1 due to duplicate name), col3, metric2
|
|
assert len(result) == 4
|
|
assert col1 in result
|
|
assert col2 in result
|
|
assert col3 in result
|
|
assert metric2 in result
|
|
assert metric1 not in result # Removed as duplicate of col2
|
|
assert orderby1 not in result # Not added when allows_hidden_orderby_agg = True
|
|
|
|
# Test with allows_hidden_orderby_agg = False
|
|
mock_engine_spec.allows_hidden_orderby_agg = False
|
|
|
|
result = table._deduplicate_select_columns(
|
|
select_exprs, metrics_exprs, orderby_exprs
|
|
)
|
|
|
|
# Should include orderby expressions
|
|
assert len(result) == 5
|
|
assert orderby1 in result
|
|
|
|
|
|
def test_apply_orderby_direction(database: Database) -> None:
|
|
"""
|
|
Test the `_apply_orderby_direction` method.
|
|
"""
|
|
from unittest.mock import Mock, patch, PropertyMock
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.sql.expression import Label
|
|
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
# Mock db_engine_spec
|
|
mock_engine_spec = Mock()
|
|
mock_engine_spec.allows_alias_in_orderby = True
|
|
mock_engine_spec.get_allows_alias_in_select = Mock(return_value=True)
|
|
mock_engine_spec.allows_hidden_cc_in_orderby = True
|
|
|
|
# Create a mock query
|
|
query = Mock()
|
|
query.order_by = Mock(return_value=query)
|
|
|
|
# Create mock expressions
|
|
col1 = Mock()
|
|
col1.name = "col1"
|
|
col1_label = Label("col1", sa.column("col1"))
|
|
col2 = Mock()
|
|
col2.name = "col2"
|
|
|
|
orderby_exprs = [col1_label, col2]
|
|
orderby: list[tuple[Union[str, Any], bool]] = [
|
|
("col1", True),
|
|
("col2", False),
|
|
] # col1 ASC, col2 DESC
|
|
select_exprs = [col1, col2]
|
|
|
|
with patch.object(
|
|
type(table), "db_engine_spec", new_callable=PropertyMock
|
|
) as mock_prop:
|
|
mock_prop.return_value = mock_engine_spec
|
|
# Test with allows_alias_in_orderby = False
|
|
mock_engine_spec.allows_alias_in_orderby = False
|
|
|
|
with patch.object(table, "database", database):
|
|
result = table._apply_orderby_direction(
|
|
query, orderby_exprs, orderby, select_exprs
|
|
)
|
|
|
|
# Should have called order_by twice
|
|
assert query.order_by.call_count == 2
|
|
|
|
# Reset for next test
|
|
query.order_by.reset_mock()
|
|
|
|
# Test with allows_alias_in_orderby = True
|
|
mock_engine_spec.allows_alias_in_orderby = True
|
|
|
|
with patch.object(table, "database", database):
|
|
result = table._apply_orderby_direction(
|
|
query, orderby_exprs, orderby, select_exprs
|
|
)
|
|
|
|
assert result == query
|
|
assert query.order_by.call_count == 2
|
|
|
|
|
|
def test_create_others_case_expression(database: Database) -> None:
|
|
"""
|
|
Test the `_create_others_case_expression` method.
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
# Test with real SQLAlchemy expressions
|
|
expr = sa.column("category")
|
|
condition = sa.column("category").in_(["A", "B", "C"])
|
|
|
|
# Test without label
|
|
result = table._create_others_case_expression(expr, condition)
|
|
|
|
# Compile to SQL to verify structure
|
|
with database.get_sqla_engine() as engine:
|
|
sql = str(
|
|
result.compile(
|
|
dialect=engine.dialect, compile_kwargs={"literal_binds": True}
|
|
)
|
|
)
|
|
|
|
assert "CASE WHEN" in sql
|
|
assert "ELSE 'Others'" in sql
|
|
|
|
# Test with label
|
|
mock_result = Mock()
|
|
with patch.object(
|
|
table, "make_sqla_column_compatible", return_value=mock_result
|
|
) as mock_make:
|
|
result = table._create_others_case_expression(expr, condition, "my_label")
|
|
|
|
assert result == mock_result
|
|
mock_make.assert_called_once()
|
|
# First argument is the CASE expression, second is the label
|
|
assert mock_make.call_args[0][1] == "my_label"
|
|
|
|
|
|
def test_process_adhoc_sql_expression(database: Database) -> None:
|
|
"""
|
|
Test the `_process_adhoc_sql_expression` method.
|
|
"""
|
|
from unittest.mock import Mock, patch
|
|
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
# Mock the _process_sql_expression method
|
|
with patch.object(
|
|
table, "_process_sql_expression", return_value="processed_sql"
|
|
) as mock_process:
|
|
# Test with template processor
|
|
template_processor = Mock()
|
|
result = table._process_adhoc_sql_expression(
|
|
"SELECT * FROM table", template_processor
|
|
)
|
|
|
|
assert result == "processed_sql"
|
|
mock_process.assert_called_once_with(
|
|
expression="SELECT * FROM table",
|
|
database_id=table.database_id,
|
|
engine=database.backend,
|
|
schema=None,
|
|
template_processor=template_processor,
|
|
)
|
|
|
|
# Test when _process_sql_expression returns None
|
|
with patch.object(table, "_process_sql_expression", return_value=None):
|
|
result = table._process_adhoc_sql_expression("SELECT * FROM table")
|
|
assert result is None
|
|
|
|
|
|
def test_build_top_groups_filter(database: Database) -> None:
|
|
"""
|
|
Test the `_build_top_groups_filter` method.
|
|
"""
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pandas as pd
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[
|
|
TableColumn(column_name="category", type="TEXT"),
|
|
TableColumn(column_name="subcategory", type="TEXT"),
|
|
],
|
|
)
|
|
|
|
# Create test DataFrame
|
|
df = pd.DataFrame(
|
|
{
|
|
"category": ["A", "A", "B", "B", "C"],
|
|
"subcategory": ["X", "Y", "X", "Y", "Z"],
|
|
"metric": [100, 200, 150, 250, 300],
|
|
}
|
|
)
|
|
|
|
# Mock groupby_series_columns
|
|
category_col = Mock()
|
|
category_col.key = "category"
|
|
subcategory_col = Mock()
|
|
subcategory_col.key = "subcategory"
|
|
|
|
groupby_series_columns = {
|
|
"category": category_col,
|
|
"subcategory": subcategory_col,
|
|
}
|
|
|
|
columns_by_name = {col.column_name: col for col in table.columns}
|
|
|
|
# Mock _get_top_groups to return a mock filter expression
|
|
mock_filter = Mock()
|
|
mock_filter.__str__ = Mock( # type: ignore[method-assign]
|
|
return_value="category IN ('A', 'B') AND subcategory IN ('X', 'Y')"
|
|
)
|
|
with patch.object(table, "_get_top_groups", return_value=mock_filter):
|
|
result = table._build_top_groups_filter(
|
|
df, groupby_series_columns, columns_by_name
|
|
)
|
|
|
|
# Should call _get_top_groups with the right dimensions
|
|
assert result == mock_filter
|
|
table._get_top_groups.assert_called_once_with( # type: ignore[attr-defined]
|
|
df, ["category", "subcategory"], groupby_series_columns, columns_by_name
|
|
)
|
|
|
|
|
|
def test_get_series_orderby_expression(database: Database) -> None:
|
|
"""
|
|
Test the `_get_series_orderby_expression` method.
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, SqlMetric
|
|
|
|
metric = SqlMetric(metric_name="sum_sales", expression="SUM(sales)")
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
metrics=[metric],
|
|
)
|
|
|
|
# Mock the metric's get_sqla_col method
|
|
mock_expr = Mock()
|
|
mock_expr.name = "sum_sales_expr"
|
|
metric.get_sqla_col = Mock(return_value=mock_expr) # type: ignore[method-assign]
|
|
|
|
# Test when series_limit_metric is None
|
|
main_metric_expr = Mock()
|
|
main_metric_expr.name = "main_metric"
|
|
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(datasource=table, series_limit_metric=None)
|
|
result = table._get_series_orderby_expression(
|
|
query_obj=query_obj,
|
|
main_metric_expr=main_metric_expr,
|
|
)
|
|
|
|
# Should return the main metric expression when series_limit_metric is None
|
|
assert result == main_metric_expr
|
|
|
|
# Test when series_limit_metric is provided
|
|
mock_orderby_expr = Mock()
|
|
with patch.object(table, "_get_series_orderby", return_value=mock_orderby_expr):
|
|
query_obj_with_metric = QueryObject(
|
|
datasource=table, series_limit_metric="sum_sales"
|
|
)
|
|
result = table._get_series_orderby_expression(
|
|
query_obj=query_obj_with_metric,
|
|
main_metric_expr=Mock(name="different_metric"),
|
|
template_processor=None,
|
|
)
|
|
|
|
# Should call _get_series_orderby
|
|
table._get_series_orderby.assert_called_once_with( # type: ignore[attr-defined]
|
|
series_limit_metric="sum_sales",
|
|
query_obj=query_obj_with_metric,
|
|
template_processor=None,
|
|
)
|
|
assert result == mock_orderby_expr
|
|
|
|
# Test with adhoc metric
|
|
adhoc_metric = {"expressionType": "SQL", "sqlExpression": "COUNT(*)"}
|
|
with patch.object(table, "_get_series_orderby", return_value=mock_orderby_expr):
|
|
query_obj_adhoc = QueryObject(
|
|
datasource=table,
|
|
series_limit_metric=adhoc_metric, # type: ignore[arg-type]
|
|
)
|
|
result = table._get_series_orderby_expression(
|
|
query_obj=query_obj_adhoc,
|
|
main_metric_expr=Mock(name="main"),
|
|
)
|
|
assert result == mock_orderby_expr
|
|
|
|
|
|
def test_build_time_filter_expression(database: Database) -> None:
|
|
"""
|
|
Test the `_build_time_filter_expression` method.
|
|
"""
|
|
from datetime import datetime
|
|
from unittest.mock import Mock, patch, PropertyMock
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[
|
|
TableColumn(column_name="timestamp_col", type="TIMESTAMP"),
|
|
],
|
|
)
|
|
|
|
# Create a mock column
|
|
col = sa.column("timestamp_col")
|
|
time_col = table.columns[0] # Use the TableColumn we created
|
|
|
|
# Mock db_engine_spec and dttm_sql_literal
|
|
mock_engine_spec = Mock()
|
|
mock_engine_spec.get_text_clause = lambda x: sa.text(x)
|
|
|
|
with patch.object(
|
|
type(table), "db_engine_spec", new_callable=PropertyMock
|
|
) as mock_prop:
|
|
mock_prop.return_value = mock_engine_spec
|
|
|
|
with patch.object(table, "dttm_sql_literal") as mock_dttm:
|
|
mock_dttm.side_effect = lambda dt, _: f"'{dt.isoformat()}'"
|
|
|
|
# Test with both start and end times
|
|
start_dttm = datetime(2023, 1, 1, 0, 0, 0)
|
|
end_dttm = datetime(2023, 12, 31, 23, 59, 59)
|
|
|
|
result = table._build_time_filter_expression(
|
|
col, start_dttm, end_dttm, time_col
|
|
)
|
|
|
|
# Should create an AND condition with >= and <
|
|
assert result is not None
|
|
result_str = str(result)
|
|
assert "AND" in result_str
|
|
assert ">=" in result_str
|
|
assert "<" in result_str
|
|
|
|
# Test with only start time
|
|
result = table._build_time_filter_expression(
|
|
col, start_dttm, None, time_col
|
|
)
|
|
result_str = str(result)
|
|
assert ">=" in result_str
|
|
assert "<" not in result_str
|
|
|
|
# Test with only end time
|
|
result = table._build_time_filter_expression(col, None, end_dttm, time_col)
|
|
result_str = str(result)
|
|
assert "<" in result_str
|
|
assert ">=" not in result_str
|
|
|
|
# Test with no times - should return true()
|
|
result = table._build_time_filter_expression(col, None, None, time_col)
|
|
result_str = str(result)
|
|
assert "true" in result_str.lower()
|
|
|
|
|
|
def test_apply_advanced_data_type_filter(database: Database) -> None:
|
|
"""
|
|
Test the `_apply_advanced_data_type_filter` method.
|
|
"""
|
|
from unittest.mock import Mock, patch
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.exceptions import AdvancedDataTypeResponseError
|
|
from superset.utils.core import FilterOperator
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
col = sa.column("test_col")
|
|
|
|
# Mock advanced data type handler
|
|
mock_adt_handler = Mock()
|
|
mock_adt_handler.translate_type = Mock(
|
|
return_value={"values": ["translated"], "error_message": None}
|
|
)
|
|
mock_adt_handler.translate_filter = Mock(return_value=sa.text("filtered_expr"))
|
|
|
|
# Mock current_app.config
|
|
with patch("superset.models.helpers.current_app") as mock_app:
|
|
mock_config = {"ADVANCED_DATA_TYPES": {"SPATIAL": mock_adt_handler}}
|
|
mock_app.config.get = Mock(
|
|
return_value=mock_config.get("ADVANCED_DATA_TYPES", {})
|
|
)
|
|
|
|
# Test successful filtering
|
|
result = table._apply_advanced_data_type_filter(
|
|
col, "SPATIAL", FilterOperator.EQUALS, "POINT(0 0)"
|
|
)
|
|
|
|
assert result is not None
|
|
mock_adt_handler.translate_type.assert_called_once_with(
|
|
{"type": "SPATIAL", "values": ["POINT(0 0)"]}
|
|
)
|
|
mock_adt_handler.translate_filter.assert_called_once_with(
|
|
col, FilterOperator.EQUALS, ["translated"]
|
|
)
|
|
|
|
# Test with list values (IN operator)
|
|
mock_adt_handler.translate_type.reset_mock()
|
|
mock_adt_handler.translate_filter.reset_mock()
|
|
|
|
result = table._apply_advanced_data_type_filter(
|
|
col, "SPATIAL", FilterOperator.IN, ["POINT(0 0)", "POINT(1 1)"]
|
|
)
|
|
|
|
mock_adt_handler.translate_type.assert_called_once_with(
|
|
{"type": "SPATIAL", "values": ["POINT(0 0)", "POINT(1 1)"]}
|
|
)
|
|
|
|
# Test error handling
|
|
mock_adt_handler.translate_type.return_value = {
|
|
"values": [],
|
|
"error_message": "Invalid spatial data",
|
|
}
|
|
|
|
with pytest.raises(AdvancedDataTypeResponseError) as exc_info:
|
|
table._apply_advanced_data_type_filter(
|
|
col, "SPATIAL", FilterOperator.EQUALS, "INVALID"
|
|
)
|
|
|
|
assert "Invalid spatial data" in str(exc_info.value)
|
|
|
|
|
|
def test_validate_query_params_valid(database: Database) -> None:
|
|
"""
|
|
Test the `_validate_query_params` method with valid parameters.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[TableColumn(column_name="col1", type="VARCHAR")],
|
|
)
|
|
|
|
# Test with valid parameters - should not raise
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(
|
|
granularity="ts_col",
|
|
is_timeseries=True,
|
|
metrics=[{"label": "metric1"}],
|
|
columns=[{"label": "col1"}],
|
|
)
|
|
table._validate_query_params(query_obj)
|
|
|
|
# Test non-timeseries without granularity - should not raise
|
|
query_obj_no_granularity = QueryObject(
|
|
granularity=None,
|
|
is_timeseries=False,
|
|
metrics=[{"label": "metric1"}],
|
|
columns=None,
|
|
)
|
|
table._validate_query_params(query_obj_no_granularity)
|
|
|
|
|
|
def test_validate_query_params_missing_granularity(database: Database) -> None:
|
|
"""
|
|
Test the `_validate_query_params` method with missing granularity for timeseries.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.exceptions import QueryObjectValidationError
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(
|
|
granularity=None,
|
|
is_timeseries=True,
|
|
metrics=[{"label": "metric1"}],
|
|
columns=None,
|
|
)
|
|
with pytest.raises(QueryObjectValidationError) as exc_info:
|
|
table._validate_query_params(query_obj)
|
|
|
|
assert "Datetime column not provided" in str(exc_info.value)
|
|
|
|
|
|
def test_validate_query_params_empty_query(database: Database) -> None:
|
|
"""
|
|
Test the `_validate_query_params` method with empty query parameters.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.exceptions import QueryObjectValidationError
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(
|
|
granularity="ts_col",
|
|
is_timeseries=False,
|
|
metrics=None,
|
|
columns=None,
|
|
)
|
|
with pytest.raises(QueryObjectValidationError) as exc_info:
|
|
table._validate_query_params(query_obj)
|
|
|
|
assert "Empty query?" in str(exc_info.value)
|
|
|
|
|
|
def test_build_time_filters_no_granularity(database: Database) -> None:
|
|
"""
|
|
Test the `_build_time_filters` method with no granularity.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.jinja_context import BaseTemplateProcessor
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
select_exprs: list[object] = []
|
|
groupby_all_columns: dict[str, object] = {}
|
|
template_processor = BaseTemplateProcessor(database=database)
|
|
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(
|
|
granularity=None, is_timeseries=False, from_dttm=None, to_dttm=None, extras={}
|
|
)
|
|
time_filters, dttm_col = table._build_time_filters(
|
|
query_obj=query_obj,
|
|
template_processor=template_processor,
|
|
select_exprs=select_exprs,
|
|
groupby_all_columns=groupby_all_columns,
|
|
)
|
|
|
|
assert time_filters == []
|
|
assert dttm_col is None
|
|
|
|
|
|
def test_build_time_filters_with_granularity(database: Database) -> None:
|
|
"""
|
|
Test the `_build_time_filters` method with granularity and timeseries.
|
|
"""
|
|
from datetime import datetime
|
|
from unittest.mock import Mock
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
from superset.jinja_context import BaseTemplateProcessor
|
|
|
|
# Create a mock datetime column
|
|
dttm_col = TableColumn(column_name="ts_col", type="TIMESTAMP")
|
|
mock_timestamp = Mock()
|
|
mock_timestamp.name = "__timestamp"
|
|
dttm_col.get_timestamp_expression = Mock(return_value=mock_timestamp) # type: ignore[method-assign]
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
columns=[dttm_col],
|
|
)
|
|
table.get_time_filter = Mock(return_value="time_filter_expr") # type: ignore[method-assign]
|
|
|
|
select_exprs: list[object] = []
|
|
groupby_all_columns: dict[str, object] = {}
|
|
template_processor = BaseTemplateProcessor(database=database)
|
|
|
|
from_dttm = datetime(2023, 1, 1)
|
|
to_dttm = datetime(2023, 12, 31)
|
|
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(
|
|
datasource=table, # Pass table as datasource so QueryObject builds mappings
|
|
granularity="ts_col",
|
|
is_timeseries=True,
|
|
from_dttm=from_dttm,
|
|
to_dttm=to_dttm,
|
|
extras={"time_grain_sqla": "P1D"},
|
|
)
|
|
time_filters, result_dttm_col = table._build_time_filters(
|
|
query_obj=query_obj,
|
|
template_processor=template_processor,
|
|
select_exprs=select_exprs,
|
|
groupby_all_columns=groupby_all_columns,
|
|
)
|
|
|
|
# Should have added timestamp to select_exprs and groupby_all_columns
|
|
assert len(select_exprs) == 1
|
|
assert select_exprs[0] == mock_timestamp
|
|
assert groupby_all_columns["__timestamp"] == mock_timestamp
|
|
|
|
# Should have called get_time_filter and returned filters
|
|
assert len(time_filters) == 1
|
|
assert time_filters[0] == "time_filter_expr"
|
|
assert result_dttm_col == dttm_col
|
|
|
|
# Verify get_time_filter was called correctly
|
|
table.get_time_filter.assert_called_once_with(
|
|
time_col=dttm_col,
|
|
start_dttm=from_dttm,
|
|
end_dttm=to_dttm,
|
|
template_processor=template_processor,
|
|
)
|
|
|
|
|
|
def test_build_time_filters_invalid_granularity(database: Database) -> None:
|
|
"""
|
|
Test the `_build_time_filters` method with invalid granularity.
|
|
"""
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.exceptions import QueryObjectValidationError
|
|
from superset.jinja_context import BaseTemplateProcessor
|
|
|
|
table = SqlaTable(
|
|
database=database,
|
|
schema=None,
|
|
table_name="test_table",
|
|
)
|
|
|
|
select_exprs: list[object] = []
|
|
groupby_all_columns: dict[str, object] = {}
|
|
template_processor = BaseTemplateProcessor(database=database)
|
|
|
|
from superset.common.query_object import QueryObject
|
|
|
|
query_obj = QueryObject(
|
|
granularity="invalid_col",
|
|
is_timeseries=True,
|
|
from_dttm=None,
|
|
to_dttm=None,
|
|
extras={},
|
|
)
|
|
with pytest.raises(QueryObjectValidationError) as exc_info:
|
|
table._build_time_filters(
|
|
query_obj=query_obj,
|
|
template_processor=template_processor,
|
|
select_exprs=select_exprs,
|
|
groupby_all_columns=groupby_all_columns,
|
|
)
|
|
|
|
assert 'Time column "invalid_col" does not exist' in str(exc_info.value)
|