Compare commits

..

5 Commits

Author SHA1 Message Date
Evan Rusackas
acf3635500 fix(test): Add missing jest-dom import to fix CountryMap tests
The tests were failing because jest-dom matchers (toHaveClass, toHaveAttribute,
toHaveStyle) were being used without importing @testing-library/jest-dom.
Also added CSS import to ensure styles are available during testing.
2025-08-03 00:02:37 -07:00
Evan Rusackas
1926212f0e removing extraneous stuff. 2025-08-02 23:21:16 -07:00
Evan Rusackas
86d5d4b4a8 fix(country-map): improve tooltip readability with proper styling
Fixes #28458

The Country Map tooltip was not readable as it displayed information in fixed text elements
instead of a proper tooltip that follows the mouse cursor like the World Map does.

Changes:
- Added tooltip CSS with dark background and white text for better contrast
- Implemented mouse-following tooltip using D3 event coordinates
- Created tooltip HTML structure with title and value sections
- Added transition effects for smooth show/hide
- Added tests to verify tooltip DOM elements are created

The tooltip now behaves consistently with other map visualizations in Superset.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 23:14:19 -07:00
Evan Rusackas
2029fd0131 fix(table): hide conditional formatting color options without time comparison
Fixes #34141

The "Green for increase, red for decrease" and "Red for increase, green for decrease"
color scheme options were showing in table chart conditional formatting even when no
time comparison was active. These options only work with time comparison data, so they
should be hidden when time_compare is empty.

Changes:
- Modified both table chart control panels to dynamically show/hide color options based on time comparison
- extraColorChoices now depends on hasTimeComparison check
- Applied fix to both regular table chart and AG Grid table chart for consistency

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 23:01:00 -07:00
Evan Rusackas
4e209e51d0 fix(sqllab): prevent strings with angle brackets from being hidden (#34512)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-02 22:53:17 -07:00
23 changed files with 916 additions and 2266 deletions

View File

@@ -49,6 +49,21 @@ describe('isProbablyHTML', () => {
const trickyText = 'a <= 10 and b > 10';
expect(isProbablyHTML(trickyText)).toBe(false);
});
it('should return false for strings with angle brackets that are not HTML', () => {
// Test case from issue #25561
expect(isProbablyHTML('<abcdef:12345>')).toBe(false);
// Other similar cases
expect(isProbablyHTML('<foo:bar>')).toBe(false);
expect(isProbablyHTML('<123>')).toBe(false);
expect(isProbablyHTML('<test@example.com>')).toBe(false);
expect(isProbablyHTML('<custom-element>')).toBe(false);
// Mathematical expressions
expect(isProbablyHTML('if x < 5 and y > 10')).toBe(false);
expect(isProbablyHTML('price < $100')).toBe(false);
});
});
describe('sanitizeHtmlIfNeeded', () => {

View File

@@ -68,9 +68,87 @@ export function isProbablyHTML(text: string) {
return true;
}
// Check if the string contains common HTML patterns
if (!hasHtmlTagPattern(text)) {
return false;
}
const parser = new DOMParser();
const doc = parser.parseFromString(cleanedStr, 'text/html');
return Array.from(doc.body.childNodes).some(({ nodeType }) => nodeType === 1);
// Check if parsing created actual HTML elements (not just text nodes)
const elements = Array.from(doc.body.childNodes).filter(
node => node.nodeType === 1,
) as Element[];
// If no elements were created, it's not HTML
if (elements.length === 0) {
return false;
}
// Check if the elements are known HTML tags (not custom/unknown tags)
// This prevents strings like "<abcdef:12345>" from being treated as HTML
return elements.some(element => {
const tagName = element.tagName.toLowerCase();
// List of common HTML tags we want to recognize
const knownHtmlTags = [
'div',
'span',
'p',
'a',
'b',
'i',
'u',
'em',
'strong',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'tr',
'td',
'th',
'tbody',
'thead',
'tfoot',
'ul',
'ol',
'li',
'img',
'br',
'hr',
'pre',
'code',
'blockquote',
'section',
'article',
'nav',
'header',
'footer',
'form',
'input',
'button',
'select',
'option',
'textarea',
'label',
'fieldset',
'legend',
'video',
'audio',
'canvas',
'iframe',
'script',
'style',
'link',
'meta',
'title',
];
return knownHtmlTags.includes(tagName);
});
}
export function sanitizeHtmlIfNeeded(htmlString: string) {

View File

@@ -59,3 +59,29 @@
cursor: pointer;
stroke: #eee;
}
.superset-legacy-chart-country-map .tooltip {
position: absolute;
text-align: left;
padding: 10px;
font-size: 12px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.superset-legacy-chart-country-map .tooltip.show {
opacity: 1;
}
.superset-legacy-chart-country-map .tooltip .tooltip-title {
font-weight: 600;
margin-bottom: 4px;
}
.superset-legacy-chart-country-map .tooltip .tooltip-value {
font-weight: 300;
}

View File

@@ -100,6 +100,12 @@ function CountryMap(element, props) {
.classed('result-text', true)
.attr('dy', '1em');
// Create tooltip
const tooltip = div
.append('div')
.attr('class', 'tooltip')
.style('opacity', 0);
let centered;
const clicked = function clicked(d) {
@@ -181,12 +187,38 @@ function CountryMap(element, props) {
region => region.country_id === d.properties.ISO,
);
updateMetrics(result);
// Show tooltip
let name = '';
if (d && d.properties) {
if (d.properties.ID_2) {
name = d.properties.NAME_2;
} else {
name = d.properties.NAME_1;
}
}
const value = result.length > 0 ? format(result[0].metric) : 'No data';
tooltip
.classed('show', true)
.html(
`<div class="tooltip-title">${name}</div>` +
`<div class="tooltip-value">${value}</div>`,
);
};
const mousemove = function mousemove() {
tooltip
.style('left', `${d3.event.pageX + 15}px`)
.style('top', `${d3.event.pageY - 28}px`);
};
const mouseout = function mouseout() {
d3.select(this).style('fill', colorFn);
bigText.text('');
resultText.text('');
tooltip.classed('show', false);
};
function drawMap(mapData) {
@@ -225,6 +257,7 @@ function CountryMap(element, props) {
.attr('vector-effect', 'non-scaling-stroke')
.style('fill', colorFn)
.on('mouseenter', mouseenter)
.on('mousemove', mousemove)
.on('mouseout', mouseout)
.on('click', clicked);
}

View File

@@ -0,0 +1,95 @@
/**
* 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.
*/
import '@testing-library/jest-dom';
import CountryMap from '../src/CountryMap';
import '../src/CountryMap.css';
describe('CountryMap', () => {
let container;
let mockProps;
beforeEach(() => {
container = document.createElement('div');
container.style.width = '800px';
container.style.height = '600px';
document.body.appendChild(container);
mockProps = {
data: [
{ country_id: 'USA', metric: 100 },
{ country_id: 'CAN', metric: 50 },
],
width: 800,
height: 600,
country: 'usa',
linearColorScheme: 'greenBlue',
numberFormat: '.3s',
colorScheme: null,
sliceId: 1,
};
});
afterEach(() => {
document.body.removeChild(container);
});
it('should create the necessary DOM elements', () => {
CountryMap(container, mockProps);
// Check if main container has the correct class
expect(container).toHaveClass('superset-legacy-chart-country-map');
// Check if SVG is created
const svg = container.querySelector('svg');
expect(svg).toBeTruthy();
expect(svg).toHaveAttribute('width', '800');
expect(svg).toHaveAttribute('height', '600');
// Check if tooltip div is created
const tooltip = container.querySelector('.tooltip');
expect(tooltip).toBeTruthy();
expect(tooltip).toHaveStyle({ opacity: '0' });
});
it('should create map layers', () => {
CountryMap(container, mockProps);
// Check if map layer exists
const mapLayer = container.querySelector('.map-layer');
expect(mapLayer).toBeTruthy();
// Check if text layer exists
const textLayer = container.querySelector('.text-layer');
expect(textLayer).toBeTruthy();
});
it('should apply tooltip styles', () => {
CountryMap(container, mockProps);
const tooltip = container.querySelector('.tooltip');
expect(tooltip).toBeTruthy();
// Check if tooltip has the correct class
expect(tooltip).toHaveClass('tooltip');
// Check if tooltip has opacity 0 initially
expect(tooltip).toHaveStyle({ opacity: '0' });
});
});

View File

@@ -673,16 +673,6 @@ const config: ControlPanelConfig = {
type: 'ConditionalFormattingControl',
renderTrigger: true,
label: t('Custom conditional formatting'),
extraColorChoices: [
{
value: ColorSchemeEnum.Green,
label: t('Green for increase, red for decrease'),
},
{
value: ColorSchemeEnum.Red,
label: t('Red for increase, green for decrease'),
},
],
description: t(
'Apply conditional color formatting to numeric columns',
),
@@ -695,6 +685,23 @@ const config: ControlPanelConfig = {
)
? (explore?.datasource as Dataset)?.verbose_map
: (explore?.datasource?.columns ?? {});
// Only show increase/decrease color options when time comparison is active
const hasTimeComparison = !isEmpty(
explore?.form_data?.time_compare,
);
const extraColorChoices = hasTimeComparison
? [
{
value: ColorSchemeEnum.Green,
label: t('Green for increase, red for decrease'),
},
{
value: ColorSchemeEnum.Red,
label: t('Red for increase, green for decrease'),
},
]
: [];
const chartStatus = chart?.chartStatus;
const { colnames, coltypes } =
chart?.queriesResponse?.[0] ?? {};
@@ -725,6 +732,7 @@ const config: ControlPanelConfig = {
removeIrrelevantConditions: chartStatus === 'success',
columnOptions,
verboseMap,
extraColorChoices,
};
},
},

View File

@@ -730,16 +730,6 @@ const config: ControlPanelConfig = {
type: 'ConditionalFormattingControl',
renderTrigger: true,
label: t('Custom conditional formatting'),
extraColorChoices: [
{
value: ColorSchemeEnum.Green,
label: t('Green for increase, red for decrease'),
},
{
value: ColorSchemeEnum.Red,
label: t('Red for increase, green for decrease'),
},
],
description: t(
'Apply conditional color formatting to numeric columns',
),
@@ -752,6 +742,23 @@ const config: ControlPanelConfig = {
)
? (explore?.datasource as Dataset)?.verbose_map
: (explore?.datasource?.columns ?? {});
// Only show increase/decrease color options when time comparison is active
const hasTimeComparison = !isEmpty(
explore?.form_data?.time_compare,
);
const extraColorChoices = hasTimeComparison
? [
{
value: ColorSchemeEnum.Green,
label: t('Green for increase, red for decrease'),
},
{
value: ColorSchemeEnum.Red,
label: t('Red for increase, green for decrease'),
},
]
: [];
const chartStatus = chart?.chartStatus;
const { colnames, coltypes } =
chart?.queriesResponse?.[0] ?? {};
@@ -782,6 +789,7 @@ const config: ControlPanelConfig = {
removeIrrelevantConditions: chartStatus === 'success',
columnOptions,
verboseMap,
extraColorChoices,
};
},
},

View File

@@ -145,7 +145,7 @@ def _get_samples(
query_obj = copy.copy(query_obj)
query_obj.is_timeseries = False
query_obj.orderby = []
query_obj.metrics = []
query_obj.metrics = None
query_obj.post_processing = []
qry_obj_cols = []
for o in datasource.columns:
@@ -168,7 +168,7 @@ def _get_drill_detail(
query_obj = copy.copy(query_obj)
query_obj.is_timeseries = False
query_obj.orderby = []
query_obj.metrics = []
query_obj.metrics = None
query_obj.post_processing = []
qry_obj_cols = []
for o in datasource.columns:

View File

@@ -86,8 +86,6 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
apply_fetch_values_predicate: bool
columns: list[Column]
datasource: BaseDatasource | None
columns_by_name: dict[str, Any]
metrics_by_name: dict[str, Any]
extras: dict[str, Any]
filter: list[QueryObjectFilterClause]
from_dttm: datetime | None
@@ -96,7 +94,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
inner_to_dttm: datetime | None
is_rowcount: bool
is_timeseries: bool
metrics: list[Metric]
metrics: list[Metric] | None
order_desc: bool
orderby: list[OrderBy]
post_processing: list[dict[str, Any]]
@@ -143,30 +141,6 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
self.apply_fetch_values_predicate = apply_fetch_values_predicate or False
self.columns = columns or []
self.datasource = datasource
# Build datasource mappings for easy lookup
self.columns_by_name: dict[str, Any] = {}
self.metrics_by_name: dict[str, Any] = {}
if datasource:
try:
if hasattr(datasource, "columns") and datasource.columns is not None:
self.columns_by_name = {
col.column_name: col for col in datasource.columns
}
except (TypeError, AttributeError):
# Handle mocked datasources or other non-iterable cases
pass
try:
if hasattr(datasource, "metrics") and datasource.metrics is not None:
self.metrics_by_name = {
metric.metric_name: metric for metric in datasource.metrics
}
except (TypeError, AttributeError):
# Handle mocked datasources or other non-iterable cases
pass
self.extras = extras or {}
self.filter = filters or []
self.granularity = granularity
@@ -218,12 +192,9 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
def is_str_or_adhoc(metric: Metric) -> bool:
return isinstance(metric, str) or is_adhoc_metric(metric)
# Track whether metrics was originally None (for need_groupby logic)
self._metrics_is_not_none = metrics is not None
self.metrics = [
self.metrics = metrics and [
x if is_str_or_adhoc(x) else x["label"] # type: ignore
for x in (metrics or [])
for x in metrics
]
def _set_post_processing(
@@ -255,20 +226,15 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
field.new_name,
)
value = kwargs[field.old_name]
if value is not None:
# Only override if the new field is not already populated with data
current_value = getattr(self, field.new_name, None)
if (
current_value
): # If field already has truthy data, don't override
if value:
if hasattr(self, field.new_name):
logger.warning(
"The field `%s` is already populated, "
"not replacing with contents from deprecated `%s`.",
"replacing value with contents from `%s`.",
field.new_name,
field.old_name,
)
else:
setattr(self, field.new_name, value)
setattr(self, field.new_name, value)
def _move_deprecated_extra_fields(self, kwargs: dict[str, Any]) -> None:
# move deprecated extras fields to extras
@@ -281,8 +247,8 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
field.new_name,
)
value = kwargs[field.old_name]
if value is not None and value != "": # Don't add empty string values
if field.new_name in self.extras:
if value:
if hasattr(self.extras, field.new_name):
logger.warning(
"The field `%s` is already populated in "
"`extras`, replacing value with contents "
@@ -296,7 +262,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
def metric_names(self) -> list[str]:
"""Return metrics names (labels), coerce adhoc metrics to strings."""
return get_metric_names(
self.metrics,
self.metrics or [],
(
self.datasource.verbose_map
if self.datasource and hasattr(self.datasource, "verbose_map")
@@ -310,428 +276,6 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
and metrics are non-empty, otherwise returns column labels."""
return get_column_names(self.columns)
@property
def time_grain(self) -> str | None:
"""Get time grain from extras."""
return (self.extras or {}).get("time_grain_sqla")
@property
def need_groupby(self) -> bool:
"""Determine if GROUP BY is needed based on metrics and columns."""
# GROUP BY is needed when there are metrics or when metrics is explicitly
# provided (even as empty list). When metrics=None, columns are just for
# selection without aggregation, so no GROUP BY needed.
return self._metrics_is_not_none
@property
def groupby(self) -> list[Column]:
"""Alias for columns (for backward compatibility/clarity)."""
return self.columns or []
def get_series_limit_prequery_obj(
self,
granularity: str | None,
inner_from_dttm: datetime | None,
inner_to_dttm: datetime | None,
orderby: list[OrderBy] | None = None,
) -> dict[str, Any]:
"""Build prequery object for series limit queries.
This is used to determine top groups when series_limit is set.
Args:
granularity: The time column name
inner_from_dttm: Inner from datetime (if different from main query)
inner_to_dttm: Inner to datetime (if different from main query)
orderby: Optional orderby to override (for series_limit_metric)
Returns:
Dictionary suitable for passing to query()
"""
from superset.utils.core import get_non_base_axis_columns
return {
"is_timeseries": False,
"row_limit": self.series_limit,
"metrics": self.metrics,
"granularity": granularity,
"groupby": self.groupby,
"from_dttm": inner_from_dttm or self.from_dttm,
"to_dttm": inner_to_dttm or self.to_dttm,
"filter": self.filter,
"orderby": orderby or [],
"extras": self.extras or {},
"columns": get_non_base_axis_columns(self.columns),
"order_desc": True,
}
def build_select_expressions( # noqa: C901
self,
granularity: str | None,
series_column_labels: set[str],
datasource: Any, # BaseDatasource
template_processor: Any,
) -> tuple[list[Any], dict[str, Any], dict[str, Any]]:
"""Build SELECT expressions for the query.
Args:
granularity: The time column name
series_column_labels: Labels of series columns
datasource: The datasource being queried
template_processor: Template processor for SQL templating
Returns:
Tuple of (select_exprs, groupby_all_columns, groupby_series_columns)
"""
from sqlalchemy import literal_column
from superset.utils.core import (
DTTM_ALIAS,
is_adhoc_column,
)
select_exprs = []
groupby_all_columns = {}
groupby_series_columns = {}
# Filter out the pseudo column __timestamp from columns
columns = [col for col in self.columns if col != DTTM_ALIAS]
if self.need_groupby:
# dedup columns while preserving order
columns = self.groupby or self.columns
for selected in columns:
if isinstance(selected, str):
# if groupby field/expr equals granularity field/expr
if selected == granularity:
table_col = self.columns_by_name[selected]
outer = table_col.get_timestamp_expression(
time_grain=self.time_grain,
label=selected,
template_processor=template_processor,
)
# if groupby field equals a selected column
elif selected in self.columns_by_name:
outer = datasource.convert_tbl_column_to_sqla_col(
self.columns_by_name[selected],
template_processor=template_processor,
)
else:
# Import here to avoid circular imports
from superset.models.helpers import validate_adhoc_subquery
selected = validate_adhoc_subquery(
selected,
datasource.database,
datasource.catalog,
datasource.schema,
datasource.database.db_engine_spec.engine,
)
outer = literal_column(f"({selected})")
outer = datasource.make_sqla_column_compatible(outer, selected)
else:
outer = datasource.adhoc_column_to_sqla(
col=selected,
template_processor=template_processor,
)
groupby_all_columns[outer.name] = outer
if (
self.is_timeseries and not series_column_labels
) or outer.name in series_column_labels:
groupby_series_columns[outer.name] = outer
select_exprs.append(outer)
elif self.columns:
with datasource.database.get_sqla_engine() as engine:
quote = engine.dialect.identifier_preparer.quote
for selected in self.columns:
if is_adhoc_column(selected):
_sql = selected["sqlExpression"]
_column_label = selected["label"]
elif isinstance(selected, str):
_sql = quote(selected)
_column_label = selected
# Import here to avoid circular imports
from superset.models.helpers import validate_adhoc_subquery
selected = validate_adhoc_subquery(
_sql,
datasource.database,
datasource.catalog,
datasource.schema,
datasource.database.db_engine_spec.engine,
)
select_exprs.append(
datasource.convert_tbl_column_to_sqla_col(
self.columns_by_name[selected],
template_processor=template_processor,
label=_column_label,
)
if selected in self.columns_by_name
else datasource.make_sqla_column_compatible(
literal_column(selected), _column_label
)
)
return select_exprs, groupby_all_columns, groupby_series_columns
def build_filter_clauses( # noqa: C901
self,
datasource: Any, # BaseDatasource
template_processor: Any,
time_filters: list[Any],
removed_filters: set[str],
applied_adhoc_filters_columns: list[Any],
rejected_adhoc_filters_columns: list[Any],
is_timeseries: bool,
dttm_col: Any,
) -> tuple[list[Any], list[Any]]:
"""Build WHERE and HAVING filter clauses for the query.
Args:
datasource: The datasource being queried
template_processor: Template processor for SQL templating
time_filters: Time-based filters to apply
removed_filters: Set of filter column names handled by Jinja templates
applied_adhoc_filters_columns: List to track applied adhoc filters
rejected_adhoc_filters_columns: List to track rejected adhoc filters
is_timeseries: Whether this is a timeseries query
dttm_col: The datetime column object
Returns:
Tuple of (where_clause_and, having_clause_and)
"""
from flask import current_app
from sqlalchemy import or_
from superset import feature_flag_manager
from superset.common.utils.time_range_utils import (
get_since_until_from_time_range,
)
from superset.exceptions import QueryObjectValidationError
from superset.utils.core import (
DTTM_ALIAS,
FilterOperator,
GenericDataType,
get_column_name,
is_adhoc_column,
)
where_clause_and = []
having_clause_and = []
# Process regular filters
for flt in self.filter:
if not all(flt.get(s) for s in ["col", "op"]):
continue
flt_col = flt["col"]
val = flt.get("val")
flt_grain = flt.get("grain")
op = FilterOperator(flt["op"].upper())
col_obj = None
sqla_col = None
if flt_col == DTTM_ALIAS and is_timeseries and dttm_col:
col_obj = dttm_col
elif is_adhoc_column(flt_col):
try:
sqla_col = datasource.adhoc_column_to_sqla(
flt_col, force_type_check=True
)
applied_adhoc_filters_columns.append(flt_col)
except Exception: # ColumnNotFoundException
rejected_adhoc_filters_columns.append(flt_col)
continue
else:
col_obj = self.columns_by_name.get(str(flt_col))
filter_grain = flt.get("grain")
if get_column_name(flt_col) in removed_filters:
# Skip generating SQLA filter when the jinja template handles it.
continue
if col_obj or sqla_col is not None:
db_engine_spec = datasource.database.db_engine_spec
if sqla_col is not None:
pass
elif col_obj and filter_grain:
sqla_col = col_obj.get_timestamp_expression(
time_grain=filter_grain, template_processor=template_processor
)
elif col_obj:
sqla_col = datasource.convert_tbl_column_to_sqla_col(
tbl_column=col_obj, template_processor=template_processor
)
col_type = col_obj.type if col_obj else None
col_spec = db_engine_spec.get_column_spec(native_type=col_type)
is_list_target = op in (
FilterOperator.IN,
FilterOperator.NOT_IN,
)
col_advanced_data_type = col_obj.advanced_data_type if col_obj else ""
if col_spec and not col_advanced_data_type:
target_generic_type = col_spec.generic_type
else:
target_generic_type = GenericDataType.STRING
eq = datasource.filter_values_handler(
values=val,
operator=op,
target_generic_type=target_generic_type,
target_native_type=col_type,
is_list_target=is_list_target,
db_engine_spec=db_engine_spec,
)
# Get ADVANCED_DATA_TYPES from config when needed
ADVANCED_DATA_TYPES = current_app.config.get("ADVANCED_DATA_TYPES", {}) # noqa: N806
if (
col_advanced_data_type != ""
and feature_flag_manager.is_feature_enabled(
"ENABLE_ADVANCED_DATA_TYPES"
)
and col_advanced_data_type in ADVANCED_DATA_TYPES
and eq is not None
):
where_clause_and.append(
datasource._apply_advanced_data_type_filter(
sqla_col, col_advanced_data_type, op, eq
)
)
elif is_list_target:
assert isinstance(eq, (tuple, list))
if len(eq) == 0:
raise QueryObjectValidationError(
"Filter value list cannot be empty"
)
if len(eq) > len(
eq_without_none := [x for x in eq if x is not None]
):
is_null_cond = sqla_col.is_(None)
if eq:
cond = or_(is_null_cond, sqla_col.in_(eq_without_none))
else:
cond = is_null_cond
else:
cond = sqla_col.in_(eq)
if op == FilterOperator.NOT_IN:
cond = ~cond
where_clause_and.append(cond)
elif op in {
FilterOperator.IS_NULL,
FilterOperator.IS_NOT_NULL,
}:
where_clause_and.append(
db_engine_spec.handle_null_filter(sqla_col, op)
)
elif op == FilterOperator.IS_TRUE:
where_clause_and.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, True)
)
elif op == FilterOperator.IS_FALSE:
where_clause_and.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, False)
)
else:
if (
op
not in {
FilterOperator.EQUALS,
FilterOperator.NOT_EQUALS,
}
and eq is None
):
raise QueryObjectValidationError(
"Must specify a value for filters with comparison operators"
)
if op in {
FilterOperator.EQUALS,
FilterOperator.NOT_EQUALS,
FilterOperator.GREATER_THAN,
FilterOperator.LESS_THAN,
FilterOperator.GREATER_THAN_OR_EQUALS,
FilterOperator.LESS_THAN_OR_EQUALS,
}:
where_clause_and.append(
db_engine_spec.handle_comparison_filter(sqla_col, op, eq)
)
elif op in {
FilterOperator.ILIKE,
FilterOperator.LIKE,
}:
if target_generic_type != GenericDataType.STRING:
import sqlalchemy as sa
sqla_col = sa.cast(sqla_col, sa.String)
if op == FilterOperator.LIKE:
where_clause_and.append(sqla_col.like(eq))
else:
where_clause_and.append(sqla_col.ilike(eq))
elif op in {FilterOperator.NOT_LIKE}:
if target_generic_type != GenericDataType.STRING:
import sqlalchemy as sa
sqla_col = sa.cast(sqla_col, sa.String)
where_clause_and.append(sqla_col.not_like(eq))
elif (
op == FilterOperator.TEMPORAL_RANGE
and isinstance(eq, str)
and col_obj is not None
):
_since, _until = get_since_until_from_time_range(
time_range=eq,
time_shift=self.time_shift,
extras=self.extras or {},
)
where_clause_and.append(
datasource.get_time_filter(
time_col=col_obj,
start_dttm=_since,
end_dttm=_until,
time_grain=flt_grain,
label=sqla_col.key,
template_processor=template_processor,
)
)
else:
raise QueryObjectValidationError(
f"Invalid filter operation type: {op}"
)
# Process WHERE and HAVING extras
if self.extras:
where = self.extras.get("where")
if where:
where = datasource._process_sql_expression(
expression=where,
database_id=datasource.database_id,
engine=datasource.database.backend,
schema=datasource.schema,
template_processor=template_processor,
)
where_clause_and += [datasource.text(where)]
having = self.extras.get("having")
if having:
having = datasource._process_sql_expression(
expression=having,
database_id=datasource.database_id,
engine=datasource.database.backend,
schema=datasource.schema,
template_processor=template_processor,
)
having_clause_and += [datasource.text(having)]
return where_clause_and, having_clause_and
def validate(
self, raise_exceptions: bool | None = True
) -> QueryObjectValidationError | None:
@@ -806,7 +350,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
"inner_to_dttm": self.inner_to_dttm,
"is_rowcount": self.is_rowcount,
"is_timeseries": self.is_timeseries,
"metrics": self.metrics if self.metrics else None,
"metrics": self.metrics,
"order_desc": self.order_desc,
"orderby": self.orderby,
"row_limit": self.row_limit,

View File

@@ -71,7 +71,6 @@ from sqlalchemy.types import JSON
from superset import db, is_feature_enabled, security_manager
from superset.commands.dataset.exceptions import DatasetNotFoundError
from superset.common.db_query_status import QueryStatus
from superset.common.query_object import QueryObject
from superset.connectors.sqla.utils import (
get_columns_description,
get_physical_table_metadata,
@@ -721,7 +720,7 @@ class AnnotationDatasource(BaseDatasource):
def query(self, query_obj: QueryObjectDict) -> QueryResult:
error_message = None
qry = db.session.query(Annotation)
qry = qry.filter(Annotation.layer_id == query_obj["filters"][0]["val"])
qry = qry.filter(Annotation.layer_id == query_obj["filter"][0]["val"])
if query_obj["from_dttm"]:
qry = qry.filter(Annotation.start_dttm >= query_obj["from_dttm"])
if query_obj["to_dttm"]:
@@ -1515,19 +1514,18 @@ class SqlaTable(
def _get_series_orderby(
self,
series_limit_metric: Metric,
query_obj: QueryObject,
metrics_by_name: dict[str, SqlMetric],
columns_by_name: dict[str, TableColumn],
template_processor: BaseTemplateProcessor | None = None,
) -> Column:
if utils.is_adhoc_metric(series_limit_metric):
assert isinstance(series_limit_metric, dict)
ob = self.adhoc_metric_to_sqla(
series_limit_metric, query_obj.columns_by_name
)
ob = self.adhoc_metric_to_sqla(series_limit_metric, columns_by_name)
elif (
isinstance(series_limit_metric, str)
and series_limit_metric in query_obj.metrics_by_name
and series_limit_metric in metrics_by_name
):
ob = query_obj.metrics_by_name[series_limit_metric].get_sqla_col(
ob = metrics_by_name[series_limit_metric].get_sqla_col(
template_processor=template_processor
)
else:
@@ -1859,10 +1857,7 @@ class SqlaTable(
"""
extra_cache_keys = super().get_extra_cache_keys(query_obj)
if self.has_extra_cache_key_calls(query_obj):
from superset.common.query_object import QueryObject
query_object = QueryObject(datasource=self, **query_obj)
sqla_query = self.get_sqla_query(query_object)
sqla_query = self.get_sqla_query(**query_obj)
extra_cache_keys += sqla_query.extra_cache_keys
return list(set(extra_cache_keys))

View File

@@ -940,7 +940,7 @@ def dataset_macro(
metrics = [metric.metric_name for metric in dataset.metrics]
query_obj = {
"is_timeseries": False,
"filters": [],
"filter": [],
"metrics": metrics if include_metrics else None,
"columns": columns,
"from_dttm": from_dttm,

File diff suppressed because it is too large Load Diff

View File

@@ -70,7 +70,7 @@ SQLGLOT_DIALECTS = {
# "denodo": ???
"dremio": Dremio,
"drill": Dialects.DRILL,
# "druid": Dialects.DRUID, # DRUID dialect not available in current sqlglot version
"druid": Dialects.DRUID,
"duckdb": Dialects.DUCKDB,
# "dynamodb": ???
# "elasticsearch": ???

View File

@@ -411,7 +411,7 @@ class BaseViz: # pylint: disable=too-many-public-methods
"groupby": groupby,
"metrics": metrics,
"row_limit": row_limit,
"filters": self.form_data.get("filters", []),
"filter": self.form_data.get("filters", []),
"timeseries_limit": limit,
"extras": extras,
"timeseries_limit_metric": timeseries_limit_metric,

View File

@@ -29,8 +29,8 @@ query_birth_names = {
"row_limit": 100,
"granularity": "ds",
"time_range": "100 years ago : now",
"series_limit": 0,
"series_limit_metric": None,
"timeseries_limit": 0,
"timeseries_limit_metric": None,
"order_desc": True,
"filters": [
{"col": "gender", "op": "==", "val": "boy"},

View File

@@ -902,9 +902,6 @@ class TestPostChartDataApi(BaseTestChartDataApi):
request_payload["queries"][0]["columns"] = ["foo", "bar", "state"]
request_payload["queries"][0]["where"] = "':abc' != ':xyz:qwerty'"
request_payload["queries"][0]["orderby"] = None
request_payload["queries"][0]["granularity"] = (
None # Virtual table has no time column
)
request_payload["queries"][0]["metrics"] = [
{
"expressionType": AdhocMetricExpressionType.SQL,
@@ -1015,7 +1012,7 @@ class TestGetChartDataApi(BaseTestChartDataApi):
"orderby": [["sum__num", False]],
"annotation_layers": [],
"row_limit": 50000,
"series_limit": 0,
"timeseries_limit": 0,
"order_desc": True,
"url_params": {},
"custom_params": {},
@@ -1068,7 +1065,7 @@ class TestGetChartDataApi(BaseTestChartDataApi):
"orderby": [["sum__num", False]],
"annotation_layers": [],
"row_limit": 50000,
"series_limit": 0,
"timeseries_limit": 0,
"order_desc": True,
"url_params": {},
"custom_params": {},
@@ -1122,7 +1119,7 @@ class TestGetChartDataApi(BaseTestChartDataApi):
"orderby": [["sum__num", False]],
"annotation_layers": [],
"row_limit": 50000,
"series_limit": 0,
"timeseries_limit": 0,
"order_desc": True,
"url_params": {},
"custom_params": {},

View File

@@ -124,6 +124,17 @@ class TestDatasource(SupersetTestCase):
else:
return
query_obj = {
"columns": ["metric"],
"filter": [],
"from_dttm": datetime.now() - timedelta(days=1),
"granularity": "additional_dttm",
"orderby": [],
"to_dttm": datetime.now() + timedelta(days=1),
"series_columns": [],
"row_limit": 1000,
"row_offset": 0,
}
table = SqlaTable(
table_name="dummy_sql_table",
database=database,
@@ -138,28 +149,13 @@ class TestDatasource(SupersetTestCase):
sql=sql,
)
from superset.common.query_object import QueryObject
query_obj = QueryObject(
columns=["metric"],
filters=[],
from_dttm=datetime.now() - timedelta(days=1),
granularity="additional_dttm",
orderby=[],
to_dttm=datetime.now() + timedelta(days=1),
series_columns=[],
row_limit=1000,
row_offset=0,
datasource=table,
)
with create_and_cleanup_table(table):
table.always_filter_main_dttm = False
result = str(table.get_sqla_query(query_obj).sqla_query.whereclause)
result = str(table.get_sqla_query(**query_obj).sqla_query.whereclause)
assert "default_dttm" not in result and "additional_dttm" in result # noqa: PT018
table.always_filter_main_dttm = True
result = str(table.get_sqla_query(query_obj).sqla_query.whereclause)
result = str(table.get_sqla_query(**query_obj).sqla_query.whereclause)
assert "default_dttm" in result and "additional_dttm" in result # noqa: PT018
def test_external_metadata_for_virtual_table(self):
@@ -588,11 +584,7 @@ def test_get_samples_with_incorrect_cc(test_client, login_as_admin, virtual_data
)
rv = test_client.post(uri, json={})
assert rv.status_code == 422
# The error handling returns a simple error message for CommandInvalidError
assert "error" in rv.json
assert (
"DUMMY CC" in rv.json["error"]
) # Check the error mentions the problematic column
assert rv.json["errors"][0]["error_type"] == "INVALID_SQL_ERROR"
@with_feature_flags(ALLOW_ADHOC_SUBQUERY=True)

View File

@@ -186,6 +186,6 @@ def _get_energy_slices():
"xscale_interval": "1",
"yscale_interval": "1",
},
"query_context": '{"datasource":{"id":12,"type":"table"},"force":false,"queries":[{"time_range":" : ","filters":[],"extras":{"time_grain_sqla":null,"having":"","where":""},"applied_time_extras":{},"columns":[],"metrics":[],"annotation_layers":[],"row_limit":5000,"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"result_format":"json","result_type":"full"}', # noqa: E501
"query_context": '{"datasource":{"id":12,"type":"table"},"force":false,"queries":[{"time_range":" : ","filters":[],"extras":{"time_grain_sqla":null,"having":"","where":""},"applied_time_extras":{},"columns":[],"metrics":[],"annotation_layers":[],"row_limit":5000,"timeseries_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"result_format":"json","result_type":"full"}', # noqa: E501
},
]

View File

@@ -591,7 +591,7 @@ chart_config: dict[str, Any] = {
},
"viz_type": "deck_path",
},
"query_context": '{"datasource":{"id":12,"type":"table"},"force":false,"queries":[{"time_range":" : ","filters":[],"extras":{"time_grain_sqla":null,"having":"","where":""},"applied_time_extras":{},"columns":[],"metrics":[],"annotation_layers":[],"row_limit":5000,"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"result_format":"json","result_type":"full"}', # noqa: E501
"query_context": '{"datasource":{"id":12,"type":"table"},"force":false,"queries":[{"time_range":" : ","filters":[],"extras":{"time_grain_sqla":null,"having":"","where":""},"applied_time_extras":{},"columns":[],"metrics":[],"annotation_layers":[],"row_limit":5000,"timeseries_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"result_format":"json","result_type":"full"}', # noqa: E501
"cache_timeout": None,
"uuid": "0c23747a-6528-4629-97bf-e4b78d3b9df1",
"version": "1.0.0",

View File

@@ -294,17 +294,14 @@ class TestQueryContext(SupersetTestCase):
payload = get_query_context("birth_names")
columns = payload["queries"][0]["columns"]
payload["queries"][0]["groupby"] = columns
payload["queries"][0]["series_limit"] = 99
payload["queries"][0]["series_limit_metric"] = "sum__num"
payload["queries"][0]["timeseries_limit"] = 99
payload["queries"][0]["timeseries_limit_metric"] = "sum__num"
del payload["queries"][0]["columns"]
# Remove granularity so granularity_sqla can be used
del payload["queries"][0]["granularity"]
payload["queries"][0]["granularity_sqla"] = "timecol"
payload["queries"][0]["having_filters"] = [{"col": "a", "op": "==", "val": "b"}]
query_context = ChartDataQueryContextSchema().load(payload)
assert len(query_context.queries) == 1
query_object = query_context.queries[0]
# granularity should be set from granularity_sqla since granularity was not set
assert query_object.granularity == "timecol"
assert query_object.columns == columns
assert query_object.series_limit == 99
@@ -523,7 +520,7 @@ class TestQueryContext(SupersetTestCase):
payload["queries"][0]["metrics"] = ["sum__num"]
payload["queries"][0]["groupby"] = ["name"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["series_limit"] = 5
payload["queries"][0]["timeseries_limit"] = 5
payload["queries"][0]["time_offsets"] = ["1 year ago", "1 year later"]
payload["queries"][0]["time_range"] = "1990 : 1991"
query_context = ChartDataQueryContextSchema().load(payload)
@@ -559,7 +556,7 @@ class TestQueryContext(SupersetTestCase):
# due to "name" is random generated, each time_offset slice will be empty
payload["queries"][0]["groupby"] = ["name"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["series_limit"] = 5
payload["queries"][0]["timeseries_limit"] = 5
payload["queries"][0]["time_offsets"] = []
payload["queries"][0]["time_range"] = "1990 : 1991"
payload["queries"][0]["granularity"] = "ds"
@@ -612,7 +609,7 @@ class TestQueryContext(SupersetTestCase):
payload["queries"][0]["metrics"] = ["sum__num"]
payload["queries"][0]["groupby"] = ["state"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["series_limit"] = 5
payload["queries"][0]["timeseries_limit"] = 5
payload["queries"][0]["time_offsets"] = []
payload["queries"][0]["time_range"] = "1980 : 1991"
payload["queries"][0]["granularity"] = "ds"
@@ -641,11 +638,9 @@ class TestQueryContext(SupersetTestCase):
def test_time_offsets_accuracy(self):
payload = get_query_context("birth_names")
payload["queries"][0]["metrics"] = ["sum__num"]
payload["queries"][0]["columns"] = [
"state"
] # Use columns instead of deprecated groupby
payload["queries"][0]["groupby"] = ["state"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["series_limit"] = 5
payload["queries"][0]["timeseries_limit"] = 5
payload["queries"][0]["time_offsets"] = []
payload["queries"][0]["time_range"] = "1980 : 1991"
payload["queries"][0]["granularity"] = "ds"
@@ -718,10 +713,10 @@ class TestQueryContext(SupersetTestCase):
"sqlExpression": "ds",
"label": "ds",
"expressionType": "SQL",
},
"name", # Add name to columns instead of using deprecated groupby
}
]
payload["queries"][0]["metrics"] = ["sum__num"]
payload["queries"][0]["groupby"] = ["name"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["row_limit"] = 100
payload["queries"][0]["row_offset"] = 10

View File

@@ -33,7 +33,6 @@ from sqlalchemy.sql import text
from sqlalchemy.sql.elements import TextClause
from superset import db
from superset.common.query_object import QueryObject
from superset.connectors.sqla.models import SqlaTable, TableColumn, SqlMetric
from superset.constants import EMPTY_STRING, NULL_STRING
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
@@ -163,7 +162,7 @@ class TestDatabaseModel(SupersetTestCase):
"count_timegrain",
],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {"time_grain_sqla": "P1D"},
}
@@ -187,8 +186,7 @@ class TestDatabaseModel(SupersetTestCase):
)
db.session.commit()
query_object = QueryObject(datasource=table, **base_query_obj)
sqla_query = table.get_sqla_query(query_object)
sqla_query = table.get_sqla_query(**base_query_obj)
query = table.database.compile_sqla_query(sqla_query.sqla_query)
# assert virtual dataset
@@ -236,13 +234,12 @@ class TestDatabaseModel(SupersetTestCase):
},
],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {"time_grain_sqla": "P1D"},
}
mock_dataset_id_from_context.return_value = table.id
query_object = QueryObject(datasource=table, **base_query_obj)
sqla_query = table.get_sqla_query(query_object)
sqla_query = table.get_sqla_query(**base_query_obj)
query = table.database.compile_sqla_query(sqla_query.sqla_query)
database = table.database
@@ -270,7 +267,7 @@ class TestDatabaseModel(SupersetTestCase):
}
],
"is_timeseries": False,
"filters": [],
"filter": [],
}
table = SqlaTable(
@@ -278,9 +275,8 @@ class TestDatabaseModel(SupersetTestCase):
)
db.session.commit()
query_object = QueryObject(datasource=table, **base_query_obj)
with pytest.raises(QueryObjectValidationError):
table.get_sqla_query(query_object)
table.get_sqla_query(**base_query_obj)
# Cleanup
db.session.delete(table)
db.session.commit()
@@ -314,7 +310,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["gender"],
"metrics": ["count"],
"is_timeseries": False,
"filters": [
"filter": [
{
"col": filter_.column,
"op": filter_.operator,
@@ -323,8 +319,7 @@ class TestDatabaseModel(SupersetTestCase):
],
"extras": {},
}
query_object = QueryObject(datasource=table, **query_obj)
sqla_query = table.get_sqla_query(query_object)
sqla_query = table.get_sqla_query(**query_obj)
sql = table.database.compile_sqla_query(sqla_query.sqla_query)
if isinstance(filter_.expected, list):
assert any([candidate in sql for candidate in filter_.expected]) # noqa: C419
@@ -349,7 +344,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["boolean_gender"],
"metrics": ["count"],
"is_timeseries": False,
"filters": [
"filter": [
{
"col": "boolean_gender",
"op": FilterOperator.IN,
@@ -358,8 +353,7 @@ class TestDatabaseModel(SupersetTestCase):
],
"extras": {},
}
query_object = QueryObject(datasource=table, **query_obj)
sqla_query = table.get_sqla_query(query_object)
sqla_query = table.get_sqla_query(**query_obj)
sql = table.database.compile_sqla_query(sqla_query.sqla_query)
dialect = table.database.get_dialect()
operand = "(true, false)"
@@ -377,7 +371,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["user"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {},
}
@@ -389,9 +383,8 @@ class TestDatabaseModel(SupersetTestCase):
)
# TODO(villebro): make it work with presto
if get_example_database().backend != "presto":
query_object = QueryObject(datasource=table, **query_obj)
with pytest.raises(QueryObjectValidationError):
table.get_sqla_query(query_object)
table.get_sqla_query(**query_obj)
def test_query_format_strip_trailing_semicolon(self):
query_obj = {
@@ -401,7 +394,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["user"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {},
}
@@ -410,8 +403,7 @@ class TestDatabaseModel(SupersetTestCase):
sql="SELECT * from test_table;",
database=get_example_database(),
)
query_object = QueryObject(datasource=table, **query_obj)
sqlaq = table.get_sqla_query(query_object)
sqlaq = table.get_sqla_query(**query_obj)
sql = table.database.compile_sqla_query(sqlaq.sqla_query)
assert sql[-1] != ";"
@@ -423,7 +415,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["grp"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
table = SqlaTable(
@@ -433,9 +425,8 @@ class TestDatabaseModel(SupersetTestCase):
)
query_obj = dict(**base_query_obj, extras={})
query_object = QueryObject(datasource=table, **query_obj)
with pytest.raises(QueryObjectValidationError):
table.get_sqla_query(query_object)
table.get_sqla_query(**query_obj)
def test_dml_statement_raises_exception(self):
base_query_obj = {
@@ -445,7 +436,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["grp"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
table = SqlaTable(
@@ -455,9 +446,8 @@ class TestDatabaseModel(SupersetTestCase):
)
query_obj = dict(**base_query_obj, extras={})
query_object = QueryObject(datasource=table, **query_obj)
with pytest.raises(QueryObjectValidationError):
table.get_sqla_query(query_object)
table.get_sqla_query(**query_obj)
def test_fetch_metadata_for_updated_virtual_table(self):
table = SqlaTable(
@@ -517,7 +507,7 @@ class TestDatabaseModel(SupersetTestCase):
}
],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {},
}
@@ -526,8 +516,7 @@ class TestDatabaseModel(SupersetTestCase):
db.session.add(database)
db.session.add(table)
db.session.commit()
query_object = QueryObject(datasource=table, **query_obj)
sqlaq = table.get_sqla_query(query_object)
sqlaq = table.get_sqla_query(**query_obj)
assert sqlaq.labels_expected == ["user", "COUNT_DISTINCT(user)"]
sql = table.database.compile_sqla_query(sqlaq.sqla_query)
assert "COUNT_DISTINCT_user__00db1" in sql
@@ -596,7 +585,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [{"col": "foo", "val": [NULL_STRING], "op": "IN"}],
"filter": [{"col": "foo", "val": [NULL_STRING], "op": "IN"}],
"is_timeseries": False,
}
)
@@ -606,7 +595,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [{"col": "foo", "val": [None], "op": "IN"}],
"filter": [{"col": "foo", "val": [None], "op": "IN"}],
"is_timeseries": False,
}
)
@@ -616,7 +605,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [{"col": "foo", "val": [EMPTY_STRING], "op": "IN"}],
"filter": [{"col": "foo", "val": [EMPTY_STRING], "op": "IN"}],
"is_timeseries": False,
}
)
@@ -626,7 +615,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [{"col": "foo", "val": [""], "op": "IN"}],
"filter": [{"col": "foo", "val": [""], "op": "IN"}],
"is_timeseries": False,
}
)
@@ -636,7 +625,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": [EMPTY_STRING, NULL_STRING, "null", "foo"],
@@ -652,7 +641,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": ['"text in double quotes"'],
@@ -668,7 +657,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": ["'text in single quotes'"],
@@ -684,7 +673,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": ['double quotes " in text'],
@@ -700,7 +689,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": ["single quotes ' in text"],
@@ -737,7 +726,7 @@ def test_should_generate_closed_and_open_time_filter_range(login_as_admin):
{
"metrics": ["count"],
"is_timeseries": False,
"filters": [],
"filter": [],
"from_dttm": datetime(2022, 1, 1),
"to_dttm": datetime(2023, 1, 1),
"granularity": "datetime_col",
@@ -774,7 +763,7 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset):
result = physical_dataset.query(
{
"metrics": ["count"],
"filters": [{"col": "col4", "val": None, "op": expected["operator"]}],
"filter": [{"col": "col4", "val": None, "op": expected["operator"]}],
"is_timeseries": False,
}
)
@@ -793,7 +782,7 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset):
physical_dataset.query(
{
"metrics": ["count"],
"filters": [{"col": "col4", "val": None, "op": flt.value}],
"filter": [{"col": "col4", "val": None, "op": flt.value}],
"is_timeseries": False,
}
)
@@ -882,7 +871,7 @@ def test_extra_cache_keys(
"groupby": ["id", "username", "email"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
query_obj = dict(**base_query_obj, extras={})
@@ -928,7 +917,7 @@ def test_extra_cache_keys_in_sql_expression(
"groupby": ["id", "username", "email"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
query_obj = dict(**base_query_obj, extras={"where": sql_expression})
@@ -971,7 +960,7 @@ def test_extra_cache_keys_in_adhoc_metrics_and_columns(
"metrics": [],
"columns": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
items: dict[str, Any] = {
@@ -1025,7 +1014,7 @@ def test_extra_cache_keys_in_dataset_metrics_and_columns(
"columns": ["username"],
"metrics": ["variable_profit"],
"is_timeseries": False,
"filters": [],
"filter": [],
}
extra_cache_keys = table.get_extra_cache_keys(query_obj)
@@ -1129,7 +1118,7 @@ def test__temporal_range_operator_in_adhoc_filter(physical_dataset):
result = physical_dataset.query(
{
"columns": ["col1", "col2"],
"filters": [
"filter": [
{
"col": "col5",
"val": "2000-01-05 : 2000-01-06",

File diff suppressed because it is too large Load Diff

View File

@@ -1,115 +0,0 @@
# 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.
from datetime import datetime
from superset.common.query_object import QueryObject
def test_get_series_limit_prequery_obj():
"""
Test get_series_limit_prequery_obj method
"""
# Create a QueryObject with series limit settings
query_object = QueryObject(
columns=["country", "year"],
metrics=["sum__sales"],
series_limit=10,
from_dttm=datetime(2020, 1, 1),
to_dttm=datetime(2021, 1, 1),
filters=[{"col": "region", "op": "IN", "val": ["US", "EU"]}],
extras={"time_grain_sqla": "P1D"},
order_desc=False,
)
# Test basic prequery object creation
prequery_obj = query_object.get_series_limit_prequery_obj(
granularity="ds",
inner_from_dttm=None,
inner_to_dttm=None,
)
assert prequery_obj["is_timeseries"] is False
assert prequery_obj["row_limit"] == 10
assert prequery_obj["metrics"] == ["sum__sales"]
assert prequery_obj["granularity"] == "ds"
assert prequery_obj["groupby"] == ["country", "year"]
assert prequery_obj["from_dttm"] == datetime(2020, 1, 1)
assert prequery_obj["to_dttm"] == datetime(2021, 1, 1)
assert prequery_obj["filter"] == [
{"col": "region", "op": "IN", "val": ["US", "EU"]}
]
assert prequery_obj["orderby"] == []
assert prequery_obj["extras"] == {"time_grain_sqla": "P1D"}
assert prequery_obj["order_desc"] is True # Always True for prequery
def test_get_series_limit_prequery_obj_with_overrides():
"""
Test get_series_limit_prequery_obj with inner dates and orderby override
"""
query_object = QueryObject(
columns=["country"],
metrics=["count"],
series_limit=5,
from_dttm=datetime(2020, 1, 1),
to_dttm=datetime(2021, 1, 1),
)
# Test with inner dates and custom orderby
inner_from = datetime(2020, 6, 1)
inner_to = datetime(2020, 12, 31)
custom_orderby = [("sum__revenue", False)]
prequery_obj = query_object.get_series_limit_prequery_obj(
granularity="date_col",
inner_from_dttm=inner_from,
inner_to_dttm=inner_to,
orderby=custom_orderby,
)
assert prequery_obj["from_dttm"] == inner_from
assert prequery_obj["to_dttm"] == inner_to
assert prequery_obj["orderby"] == custom_orderby
def test_get_series_limit_prequery_obj_base_axis_filtering():
"""
Test that base axis columns are filtered out in prequery
"""
# Mock the x-axis column with proper structure for base axis
query_object = QueryObject(
columns=[
{
"label": "__timestamp",
"sqlExpression": "__timestamp",
"columnType": "BASE_AXIS",
},
"country",
"city",
],
metrics=["revenue"],
series_limit=20,
)
prequery_obj = query_object.get_series_limit_prequery_obj(
granularity=None,
inner_from_dttm=None,
inner_to_dttm=None,
)
# The columns in prequery should exclude the base axis column
assert prequery_obj["columns"] == ["country", "city"]