mirror of
https://github.com/apache/superset.git
synced 2026-08-12 11:11:01 +00:00
fix(query): order by adhoc metrics should trigger group by (#13434)
* fix(query): properly select adhoc metrics in orderby * Throw error when sql is empty * Allow `metrics` to be None * Always use alias in orderby for metrics * Bump table chart version and migrate histogram to typescript * Fix Histogram without groupby * Fix Presto birth names test * Raw records mode should not aggregate
This commit is contained in:
+5
-5
@@ -16,8 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryFormData } from '@superset-ui/core';
|
||||
|
||||
describe('Visualization > Histogram', () => {
|
||||
const HISTOGRAM_FORM_DATA = {
|
||||
const HISTOGRAM_FORM_DATA: QueryFormData = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'histogram',
|
||||
slice_id: 60,
|
||||
@@ -36,7 +38,7 @@ describe('Visualization > Histogram', () => {
|
||||
normalized: false,
|
||||
};
|
||||
|
||||
function verify(formData) {
|
||||
function verify(formData: QueryFormData) {
|
||||
cy.visitChartByParams(JSON.stringify(formData));
|
||||
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
|
||||
}
|
||||
@@ -73,12 +75,10 @@ describe('Visualization > Histogram', () => {
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
clause: 'WHERE',
|
||||
subject: 'state',
|
||||
operator: '==',
|
||||
comparator: 'CA',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
|
||||
},
|
||||
],
|
||||
});
|
||||
+19
-3
@@ -151,6 +151,21 @@ describe('Visualization > Table', () => {
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Test table with groupby + order by + no metric', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: [],
|
||||
groupby: ['name'],
|
||||
timeseries_limit_metric: NUM_METRIC,
|
||||
order_desc: true,
|
||||
});
|
||||
// should contain only the group by column
|
||||
cy.get('.chart-container th').its('length').should('eq', 1);
|
||||
// should order correctly
|
||||
cy.get('.chart-container td:eq(0)').contains('Michael');
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
});
|
||||
|
||||
it('Test table with groupby and limit', () => {
|
||||
const limit = 10;
|
||||
const formData = {
|
||||
@@ -172,9 +187,9 @@ describe('Visualization > Table', () => {
|
||||
...VIZ_DEFAULTS,
|
||||
// should still work when query_mode is not-set/invalid
|
||||
query_mode: undefined,
|
||||
all_columns: ['name'],
|
||||
all_columns: ['state'],
|
||||
metrics: [],
|
||||
row_limit: 10,
|
||||
row_limit: 100,
|
||||
});
|
||||
|
||||
// should display in raw records mode
|
||||
@@ -183,8 +198,9 @@ describe('Visualization > Table', () => {
|
||||
cy.get('div[data-test="groupby"]').should('not.exist');
|
||||
|
||||
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
|
||||
cy.get('[data-test="row-count-label"]').contains('100 rows');
|
||||
|
||||
// should allow switch to aggregate mode
|
||||
// should allow switch back to aggregate mode
|
||||
cy.get('div[data-test="query_mode"] .btn').contains('Aggregate').click();
|
||||
cy.get('div[data-test="query_mode"] .btn.active').contains('Aggregate');
|
||||
cy.get('div[data-test="all_columns"]').should('not.exist');
|
||||
|
||||
+8678
-19
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@cypress/code-coverage": "^3.9.2",
|
||||
"@superset-ui/core": "^0.17.18",
|
||||
"react-dom": "^16.13.0",
|
||||
"rison": "^0.1.1",
|
||||
"shortid": "^2.2.15"
|
||||
},
|
||||
|
||||
Generated
+8
-8
@@ -14,7 +14,7 @@
|
||||
"@data-ui/sparkline": "^0.0.84",
|
||||
"@emotion/core": "^10.0.35",
|
||||
"@superset-ui/chart-controls": "^0.17.19",
|
||||
"@superset-ui/core": "^0.17.15",
|
||||
"@superset-ui/core": "^0.17.18",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.19",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "^0.17.19",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.19",
|
||||
@@ -38,7 +38,7 @@
|
||||
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.6",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.19",
|
||||
"@superset-ui/plugin-chart-echarts": "^0.17.19",
|
||||
"@superset-ui/plugin-chart-table": "^0.17.19",
|
||||
"@superset-ui/plugin-chart-table": "^0.17.20",
|
||||
"@superset-ui/plugin-chart-word-cloud": "^0.17.19",
|
||||
"@superset-ui/preset-chart-xy": "^0.17.19",
|
||||
"@vx/responsive": "^0.0.195",
|
||||
@@ -16518,9 +16518,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@superset-ui/plugin-chart-table": {
|
||||
"version": "0.17.19",
|
||||
"resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-table/-/plugin-chart-table-0.17.19.tgz",
|
||||
"integrity": "sha512-pYoR4RSUI2dbIDnuBSpa0WchMLnKX5QlVeAnFywp6T3UdPovOdcSVVSWrdfDrmX6UqSEKsje5Rj42FQWjgoAsQ==",
|
||||
"version": "0.17.20",
|
||||
"resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-table/-/plugin-chart-table-0.17.20.tgz",
|
||||
"integrity": "sha512-gWcHbt36FTvFPc0MZzX+EHgZDiB8jwdAvMQX2AFeb9BEiba45b9ZdkuLyslqa4LQM84KCuVD+V+I29VplacTAg==",
|
||||
"dependencies": {
|
||||
"@emotion/core": "^10.0.28",
|
||||
"@superset-ui/chart-controls": "0.17.19",
|
||||
@@ -72450,9 +72450,9 @@
|
||||
}
|
||||
},
|
||||
"@superset-ui/plugin-chart-table": {
|
||||
"version": "0.17.19",
|
||||
"resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-table/-/plugin-chart-table-0.17.19.tgz",
|
||||
"integrity": "sha512-pYoR4RSUI2dbIDnuBSpa0WchMLnKX5QlVeAnFywp6T3UdPovOdcSVVSWrdfDrmX6UqSEKsje5Rj42FQWjgoAsQ==",
|
||||
"version": "0.17.20",
|
||||
"resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-table/-/plugin-chart-table-0.17.20.tgz",
|
||||
"integrity": "sha512-gWcHbt36FTvFPc0MZzX+EHgZDiB8jwdAvMQX2AFeb9BEiba45b9ZdkuLyslqa4LQM84KCuVD+V+I29VplacTAg==",
|
||||
"requires": {
|
||||
"@emotion/core": "^10.0.28",
|
||||
"@superset-ui/chart-controls": "0.17.19",
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"@data-ui/sparkline": "^0.0.84",
|
||||
"@emotion/core": "^10.0.35",
|
||||
"@superset-ui/chart-controls": "^0.17.19",
|
||||
"@superset-ui/core": "^0.17.15",
|
||||
"@superset-ui/core": "^0.17.18",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.19",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "^0.17.19",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.19",
|
||||
@@ -90,7 +90,7 @@
|
||||
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.6",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.19",
|
||||
"@superset-ui/plugin-chart-echarts": "^0.17.19",
|
||||
"@superset-ui/plugin-chart-table": "^0.17.19",
|
||||
"@superset-ui/plugin-chart-table": "^0.17.20",
|
||||
"@superset-ui/plugin-chart-word-cloud": "^0.17.19",
|
||||
"@superset-ui/preset-chart-xy": "^0.17.19",
|
||||
"@vx/responsive": "^0.0.195",
|
||||
|
||||
@@ -91,10 +91,14 @@ export const DisplayQueryButton = props => {
|
||||
setError(null);
|
||||
})
|
||||
.catch(response => {
|
||||
getClientErrorObject(response).then(({ error, statusText }) => {
|
||||
setError(error || statusText || t('Sorry, An error occurred'));
|
||||
setIsLoading(false);
|
||||
});
|
||||
getClientErrorObject(response).then(
|
||||
({ error, message, statusText }) => {
|
||||
setError(
|
||||
error || message || statusText || t('Sorry, An error occurred'),
|
||||
);
|
||||
setIsLoading(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -80,10 +80,12 @@ def _get_query(
|
||||
query_context: "QueryContext", query_obj: "QueryObject", _: bool,
|
||||
) -> Dict[str, Any]:
|
||||
datasource = _get_datasource(query_context, query_obj)
|
||||
return {
|
||||
"query": datasource.get_query_str(query_obj.to_dict()),
|
||||
"language": datasource.query_language,
|
||||
}
|
||||
result = {"language": datasource.query_language}
|
||||
try:
|
||||
result["query"] = datasource.get_query_str(query_obj.to_dict())
|
||||
except QueryObjectValidationError as err:
|
||||
result["error"] = err.message
|
||||
return result
|
||||
|
||||
|
||||
def _get_full(
|
||||
@@ -118,7 +120,7 @@ def _get_full(
|
||||
] + rejected_time_columns
|
||||
|
||||
if result_type == ChartDataResultType.RESULTS and status != QueryStatus.FAILED:
|
||||
return {"data": payload["data"]}
|
||||
return {"data": payload.get("data")}
|
||||
return payload
|
||||
|
||||
|
||||
@@ -143,7 +145,7 @@ def _get_results(
|
||||
query_context: "QueryContext", query_obj: "QueryObject", force_cached: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
payload = _get_full(query_context, query_obj, force_cached)
|
||||
return {"data": payload["data"]}
|
||||
return {"data": payload.get("data"), "error": payload.get("error")}
|
||||
|
||||
|
||||
_result_type_functions: Dict[
|
||||
|
||||
@@ -129,7 +129,7 @@ class QueryContext:
|
||||
if self.enforce_numerical_metrics:
|
||||
self.df_metrics_to_num(df, query_object)
|
||||
|
||||
df.replace([np.inf, -np.inf], np.nan)
|
||||
df.replace([np.inf, -np.inf], np.nan, inplace=True)
|
||||
df = query_object.exec_post_processing(df)
|
||||
|
||||
return {
|
||||
@@ -345,7 +345,7 @@ class QueryContext:
|
||||
col
|
||||
for col in query_obj.columns
|
||||
+ query_obj.groupby
|
||||
+ get_column_names_from_metrics(query_obj.metrics)
|
||||
+ get_column_names_from_metrics(query_obj.metrics or [])
|
||||
if col not in self.datasource.column_names and col != DTTM_ALIAS
|
||||
]
|
||||
if invalid_columns:
|
||||
|
||||
@@ -28,7 +28,7 @@ from superset import app, db
|
||||
from superset.connectors.base.models import BaseDatasource
|
||||
from superset.connectors.connector_registry import ConnectorRegistry
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
from superset.typing import Metric
|
||||
from superset.typing import Metric, OrderBy
|
||||
from superset.utils import pandas_postprocessing
|
||||
from superset.utils.core import (
|
||||
ChartDataResultType,
|
||||
@@ -36,6 +36,7 @@ from superset.utils.core import (
|
||||
DTTM_ALIAS,
|
||||
find_duplicates,
|
||||
get_metric_names,
|
||||
is_adhoc_metric,
|
||||
json_int_dttm_ser,
|
||||
)
|
||||
from superset.utils.date_parser import get_since_until, parse_human_timedelta
|
||||
@@ -80,7 +81,7 @@ class QueryObject:
|
||||
is_timeseries: bool
|
||||
time_shift: Optional[timedelta]
|
||||
groupby: List[str]
|
||||
metrics: List[Union[Dict[str, Any], str]]
|
||||
metrics: Optional[List[Metric]]
|
||||
row_limit: int
|
||||
row_offset: int
|
||||
filter: List[Dict[str, Any]]
|
||||
@@ -89,7 +90,7 @@ class QueryObject:
|
||||
order_desc: bool
|
||||
extras: Dict[str, Any]
|
||||
columns: List[str]
|
||||
orderby: List[List[str]]
|
||||
orderby: List[OrderBy]
|
||||
post_processing: List[Dict[str, Any]]
|
||||
datasource: Optional[BaseDatasource]
|
||||
result_type: Optional[ChartDataResultType]
|
||||
@@ -116,11 +117,16 @@ class QueryObject:
|
||||
order_desc: bool = True,
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
columns: Optional[List[str]] = None,
|
||||
orderby: Optional[List[List[str]]] = None,
|
||||
orderby: Optional[List[OrderBy]] = None,
|
||||
post_processing: Optional[List[Optional[Dict[str, Any]]]] = None,
|
||||
is_rowcount: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
columns = columns or []
|
||||
groupby = groupby or []
|
||||
extras = extras or {}
|
||||
annotation_layers = annotation_layers or []
|
||||
|
||||
self.is_rowcount = is_rowcount
|
||||
self.datasource = None
|
||||
if datasource:
|
||||
@@ -128,12 +134,7 @@ class QueryObject:
|
||||
str(datasource["type"]), int(datasource["id"]), db.session
|
||||
)
|
||||
self.result_type = result_type
|
||||
annotation_layers = annotation_layers or []
|
||||
self.apply_fetch_values_predicate = apply_fetch_values_predicate or False
|
||||
metrics = metrics or []
|
||||
columns = columns or []
|
||||
groupby = groupby or []
|
||||
extras = extras or {}
|
||||
self.annotation_layers = [
|
||||
layer
|
||||
for layer in annotation_layers
|
||||
@@ -169,11 +170,11 @@ class QueryObject:
|
||||
# 1. 'metric_name' - name of predefined metric
|
||||
# 2. { label: 'label_name' } - legacy format for a predefined metric
|
||||
# 3. { expressionType: 'SIMPLE' | 'SQL', ... } - adhoc metric
|
||||
self.metrics = [
|
||||
metric
|
||||
if isinstance(metric, str) or "expressionType" in metric
|
||||
else metric["label"] # type: ignore
|
||||
for metric in metrics
|
||||
self.metrics = metrics and [
|
||||
x
|
||||
if isinstance(x, str) or is_adhoc_metric(x)
|
||||
else x["label"] # type: ignore
|
||||
for x in metrics
|
||||
]
|
||||
|
||||
self.row_limit = config["ROW_LIMIT"] if row_limit is None else row_limit
|
||||
@@ -236,7 +237,7 @@ class QueryObject:
|
||||
@property
|
||||
def metric_names(self) -> List[str]:
|
||||
"""Return metrics names (labels), coerce adhoc metrics to strings."""
|
||||
return get_metric_names(self.metrics)
|
||||
return get_metric_names(self.metrics or [])
|
||||
|
||||
@property
|
||||
def column_names(self) -> List[str]:
|
||||
|
||||
@@ -112,11 +112,12 @@ class BaseDatasource(
|
||||
update_from_object_fields: List[str]
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
if self.sql:
|
||||
return DatasourceKind.VIRTUAL.value
|
||||
def kind(self) -> DatasourceKind:
|
||||
return DatasourceKind.VIRTUAL if self.sql else DatasourceKind.PHYSICAL
|
||||
|
||||
return DatasourceKind.PHYSICAL.value
|
||||
@property
|
||||
def is_virtual(self) -> bool:
|
||||
return self.kind == DatasourceKind.VIRTUAL
|
||||
|
||||
@declared_attr
|
||||
def slices(self) -> RelationshipProperty:
|
||||
|
||||
@@ -48,6 +48,7 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import backref, Query, relationship, RelationshipProperty, Session
|
||||
from sqlalchemy.schema import UniqueConstraint
|
||||
from sqlalchemy.sql import column, ColumnElement, literal_column, table, text
|
||||
from sqlalchemy.sql.elements import ColumnClause
|
||||
from sqlalchemy.sql.expression import Label, Select, TextAsFrom, TextClause
|
||||
from sqlalchemy.types import TypeEngine
|
||||
|
||||
@@ -67,7 +68,7 @@ from superset.models.core import Database
|
||||
from superset.models.helpers import AuditMixinNullable, QueryResult
|
||||
from superset.result_set import SupersetResultSet
|
||||
from superset.sql_parse import ParsedQuery
|
||||
from superset.typing import Metric, QueryObjectDict
|
||||
from superset.typing import AdhocMetric, Metric, OrderBy, QueryObjectDict
|
||||
from superset.utils import core as utils
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
@@ -75,6 +76,8 @@ config = app.config
|
||||
metadata = Model.metadata # pylint: disable=no-member
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VIRTUAL_TABLE_ALIAS = "virtual_table"
|
||||
|
||||
|
||||
class SqlaQuery(NamedTuple):
|
||||
extra_cache_keys: List[Any]
|
||||
@@ -378,7 +381,7 @@ class SqlMetric(Model, BaseMetric):
|
||||
|
||||
def get_sqla_col(self, label: Optional[str] = None) -> Column:
|
||||
label = label or self.metric_name
|
||||
sqla_col = literal_column(self.expression)
|
||||
sqla_col: ColumnClause = literal_column(self.expression)
|
||||
return self.table.make_sqla_column_compatible(sqla_col, label)
|
||||
|
||||
@property
|
||||
@@ -505,10 +508,10 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
"""
|
||||
label_expected = label or sqla_col.name
|
||||
db_engine_spec = self.database.db_engine_spec
|
||||
if db_engine_spec.allows_column_aliases:
|
||||
# add quotes to tables
|
||||
if db_engine_spec.allows_alias_in_select:
|
||||
label = db_engine_spec.make_label_compatible(label_expected)
|
||||
sqla_col = sqla_col.label(label)
|
||||
sqla_col._df_label_expected = label_expected # pylint: disable=protected-access
|
||||
return sqla_col
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -796,40 +799,53 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
def get_from_clause(
|
||||
self, template_processor: Optional[BaseTemplateProcessor] = None
|
||||
) -> Union[table, TextAsFrom]:
|
||||
# Supporting arbitrary SQL statements in place of tables
|
||||
if self.sql:
|
||||
from_sql = self.sql
|
||||
if template_processor:
|
||||
try:
|
||||
from_sql = template_processor.process_template(from_sql)
|
||||
except TemplateError as ex:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Error in jinja expression in FROM clause: %(msg)s",
|
||||
msg=ex.message,
|
||||
)
|
||||
)
|
||||
"""
|
||||
Return where to select the columns and metrics from. Either a physical table
|
||||
or a virtual table with it's own subquery.
|
||||
"""
|
||||
if not self.is_virtual:
|
||||
return self.get_sqla_table()
|
||||
|
||||
from_sql = sqlparse.format(from_sql, strip_comments=True)
|
||||
if len(sqlparse.split(from_sql)) > 1:
|
||||
from_sql = self.get_rendered_sql(template_processor)
|
||||
parsed_query = ParsedQuery(from_sql)
|
||||
db_engine_spec = self.database.db_engine_spec
|
||||
if not (
|
||||
parsed_query.is_unknown() or db_engine_spec.is_readonly_query(parsed_query)
|
||||
):
|
||||
raise QueryObjectValidationError(
|
||||
_("Virtual dataset query must be read-only")
|
||||
)
|
||||
return TextAsFrom(sa.text(from_sql), []).alias(VIRTUAL_TABLE_ALIAS)
|
||||
|
||||
def get_rendered_sql(
|
||||
self, template_processor: Optional[BaseTemplateProcessor] = None
|
||||
) -> str:
|
||||
"""
|
||||
Render sql with template engine (Jinja).
|
||||
"""
|
||||
sql = self.sql
|
||||
if template_processor:
|
||||
try:
|
||||
sql = template_processor.process_template(sql)
|
||||
except TemplateError as ex:
|
||||
raise QueryObjectValidationError(
|
||||
_("Virtual dataset query cannot consist of multiple statements")
|
||||
_(
|
||||
"Error while rendering virtual dataset query: %(msg)s",
|
||||
msg=ex.message,
|
||||
)
|
||||
)
|
||||
parsed_query = ParsedQuery(from_sql)
|
||||
db_engine_spec = self.database.db_engine_spec
|
||||
if not (
|
||||
parsed_query.is_unknown()
|
||||
or db_engine_spec.is_readonly_query(parsed_query)
|
||||
):
|
||||
raise QueryObjectValidationError(
|
||||
_("Virtual dataset query must be read-only")
|
||||
)
|
||||
return TextAsFrom(sa.text(from_sql), []).alias("expr_qry")
|
||||
return self.get_sqla_table()
|
||||
sql = sqlparse.format(sql, strip_comments=True)
|
||||
if not sql:
|
||||
raise QueryObjectValidationError(_("Virtual dataset query cannot be empty"))
|
||||
if len(sqlparse.split(sql)) > 1:
|
||||
raise QueryObjectValidationError(
|
||||
_("Virtual dataset query cannot consist of multiple statements")
|
||||
)
|
||||
return sql
|
||||
|
||||
def adhoc_metric_to_sqla(
|
||||
self, metric: Dict[str, Any], columns_by_name: Dict[str, Any]
|
||||
) -> Optional[Column]:
|
||||
self, metric: AdhocMetric, columns_by_name: Dict[str, TableColumn]
|
||||
) -> Column:
|
||||
"""
|
||||
Turn an adhoc metric into a sqlalchemy column.
|
||||
|
||||
@@ -843,7 +859,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
|
||||
if expression_type == utils.AdhocMetricExpressionType.SIMPLE:
|
||||
column_name = metric["column"].get("column_name")
|
||||
table_column = columns_by_name.get(column_name)
|
||||
table_column: Optional[TableColumn] = columns_by_name.get(column_name)
|
||||
if table_column:
|
||||
sqla_column = table_column.get_sqla_col()
|
||||
else:
|
||||
@@ -852,7 +868,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
elif expression_type == utils.AdhocMetricExpressionType.SQL:
|
||||
sqla_metric = literal_column(metric.get("sqlExpression"))
|
||||
else:
|
||||
return None
|
||||
raise QueryObjectValidationError("Adhoc metric expressionType is invalid")
|
||||
|
||||
return self.make_sqla_column_compatible(sqla_metric, label)
|
||||
|
||||
@@ -883,10 +899,10 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
|
||||
def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-many-branches,too-many-statements
|
||||
self,
|
||||
metrics: List[Metric],
|
||||
granularity: str,
|
||||
from_dttm: Optional[datetime],
|
||||
to_dttm: Optional[datetime],
|
||||
metrics: Optional[List[Metric]] = None,
|
||||
granularity: Optional[str] = None,
|
||||
from_dttm: Optional[datetime] = None,
|
||||
to_dttm: Optional[datetime] = None,
|
||||
columns: Optional[List[str]] = None,
|
||||
groupby: Optional[List[str]] = None,
|
||||
filter: Optional[ # pylint: disable=redefined-builtin
|
||||
@@ -899,7 +915,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
row_offset: Optional[int] = None,
|
||||
inner_from_dttm: Optional[datetime] = None,
|
||||
inner_to_dttm: Optional[datetime] = None,
|
||||
orderby: Optional[List[Tuple[ColumnElement, bool]]] = None,
|
||||
orderby: Optional[List[OrderBy]] = None,
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
order_desc: bool = True,
|
||||
is_rowcount: bool = False,
|
||||
@@ -922,8 +938,10 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
template_processor = self.get_template_processor(**template_kwargs)
|
||||
db_engine_spec = self.database.db_engine_spec
|
||||
prequeries: List[str] = []
|
||||
|
||||
orderby = orderby or []
|
||||
extras = extras or {}
|
||||
need_groupby = bool(metrics is not None or groupby)
|
||||
metrics = metrics or []
|
||||
|
||||
# For backward compatibility
|
||||
if granularity not in self.dttm_cols:
|
||||
@@ -965,19 +983,47 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
main_metric_expr, label = literal_column("COUNT(*)"), "ccount"
|
||||
main_metric_expr = self.make_sqla_column_compatible(main_metric_expr, label)
|
||||
|
||||
select_exprs: List[Column] = []
|
||||
groupby_exprs_sans_timestamp = OrderedDict()
|
||||
# To ensure correct handling of the ORDER BY labeling we need to reference the
|
||||
# metric instance if defined in the SELECT clause.
|
||||
metrics_exprs_by_label = {
|
||||
m.name: m for m in metrics_exprs # pylint: disable=protected-access
|
||||
}
|
||||
|
||||
assert extras is not None
|
||||
# Since orderby may use adhoc metrics, too; we need to process them first
|
||||
orderby_exprs: List[ColumnElement] = []
|
||||
for orig_col, ascending in orderby:
|
||||
col: Union[Metric, ColumnElement] = orig_col
|
||||
if isinstance(col, dict):
|
||||
if utils.is_adhoc_metric(col):
|
||||
# add adhoc sort by column to columns_by_name if not exists
|
||||
col = self.adhoc_metric_to_sqla(col, columns_by_name)
|
||||
need_groupby = True
|
||||
elif col in columns_by_name:
|
||||
col = columns_by_name[col].get_sqla_col()
|
||||
elif col in metrics_by_name:
|
||||
col = metrics_by_name[col].get_sqla_col()
|
||||
need_groupby = True
|
||||
elif col in metrics_exprs_by_label:
|
||||
col = metrics_exprs_by_label[col]
|
||||
|
||||
if isinstance(col, ColumnElement):
|
||||
orderby_exprs.append(col)
|
||||
else:
|
||||
# Could not convert a column reference to valid ColumnElement
|
||||
raise QueryObjectValidationError(
|
||||
_("Unknown column used in orderby: %(col)", col=orig_col)
|
||||
)
|
||||
|
||||
select_exprs: List[Union[Column, Label]] = []
|
||||
groupby_exprs_sans_timestamp = OrderedDict()
|
||||
|
||||
# filter out the pseudo column __timestamp from columns
|
||||
columns = columns or []
|
||||
columns = [col for col in columns if col != utils.DTTM_ALIAS]
|
||||
|
||||
if metrics or groupby:
|
||||
if need_groupby:
|
||||
# dedup columns while preserving order
|
||||
columns = groupby or columns
|
||||
select_exprs = []
|
||||
for selected in columns:
|
||||
# if groupby field/expr equals granularity field/expr
|
||||
if selected == granularity:
|
||||
@@ -1005,6 +1051,13 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
groupby_exprs_with_timestamp = OrderedDict(groupby_exprs_sans_timestamp.items())
|
||||
|
||||
if granularity:
|
||||
if granularity not in columns_by_name:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
'Time column "%(col)s" does not exist in dataset',
|
||||
col=granularity,
|
||||
)
|
||||
)
|
||||
dttm_col = columns_by_name[granularity]
|
||||
time_grain = extras.get("time_grain_sqla")
|
||||
time_filters = []
|
||||
@@ -1031,12 +1084,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
)
|
||||
|
||||
select_exprs += metrics_exprs
|
||||
|
||||
labels_expected = [
|
||||
c._df_label_expected # pylint: disable=protected-access
|
||||
for c in select_exprs
|
||||
]
|
||||
|
||||
labels_expected = [c.name for c in select_exprs]
|
||||
select_exprs = db_engine_spec.make_select_compatible(
|
||||
groupby_exprs_with_timestamp.values(), select_exprs
|
||||
)
|
||||
@@ -1149,26 +1197,13 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
qry = qry.where(and_(*where_clause_and))
|
||||
qry = qry.having(and_(*having_clause_and))
|
||||
|
||||
# To ensure correct handling of the ORDER BY labeling we need to reference the
|
||||
# metric instance if defined in the SELECT clause.
|
||||
metrics_exprs_by_label = {
|
||||
m._label: m for m in metrics_exprs # pylint: disable=protected-access
|
||||
}
|
||||
|
||||
for col, ascending in orderby:
|
||||
for col, (orig_col, ascending) in zip(orderby_exprs, orderby):
|
||||
if (
|
||||
db_engine_spec.allows_alias_in_orderby
|
||||
and col.name in metrics_exprs_by_label
|
||||
):
|
||||
col = Label(col.name, metrics_exprs_by_label[col.name])
|
||||
direction = asc if ascending else desc
|
||||
if utils.is_adhoc_metric(col):
|
||||
col = self.adhoc_metric_to_sqla(col, columns_by_name)
|
||||
elif col in columns_by_name:
|
||||
col = columns_by_name[col].get_sqla_col()
|
||||
elif col in metrics_by_name:
|
||||
col = metrics_by_name[col].get_sqla_col()
|
||||
|
||||
if isinstance(col, Label):
|
||||
label = col._label # pylint: disable=protected-access
|
||||
if label in metrics_exprs_by_label:
|
||||
col = metrics_exprs_by_label[label]
|
||||
|
||||
qry = qry.order_by(direction(col))
|
||||
|
||||
if row_limit:
|
||||
@@ -1182,7 +1217,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
and not time_groupby_inline
|
||||
and groupby
|
||||
):
|
||||
if self.database.db_engine_spec.allows_joins:
|
||||
if db_engine_spec.allows_joins:
|
||||
# some sql dialects require for order by expressions
|
||||
# to also be in the select clause -- others, e.g. vertica,
|
||||
# require a unique inner alias
|
||||
@@ -1287,7 +1322,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
timeseries_limit_metric: Metric,
|
||||
metrics_by_name: Dict[str, SqlMetric],
|
||||
columns_by_name: Dict[str, TableColumn],
|
||||
) -> Optional[Column]:
|
||||
) -> Column:
|
||||
if utils.is_adhoc_metric(timeseries_limit_metric):
|
||||
assert isinstance(timeseries_limit_metric, dict)
|
||||
ob = self.adhoc_metric_to_sqla(timeseries_limit_metric, columns_by_name)
|
||||
@@ -1300,7 +1335,6 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
raise QueryObjectValidationError(
|
||||
_("Metric '%(metric)s' does not exist", metric=timeseries_limit_metric)
|
||||
)
|
||||
|
||||
return ob
|
||||
|
||||
def _get_top_groups( # pylint: disable=no-self-use
|
||||
@@ -1326,27 +1360,33 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
||||
errors = None
|
||||
error_message = None
|
||||
|
||||
def mutator(df: pd.DataFrame) -> None:
|
||||
def assign_column_label(df: pd.DataFrame) -> Optional[pd.DataFrame]:
|
||||
"""
|
||||
Some engines change the case or generate bespoke column names, either by
|
||||
default or due to lack of support for aliasing. This function ensures that
|
||||
the column names in the DataFrame correspond to what is expected by
|
||||
the viz components.
|
||||
|
||||
:param df: Original DataFrame returned by the engine
|
||||
"""
|
||||
Sometimes a query may also contain only order by columns that are not used
|
||||
as metrics or groupby columns, but need to present in the SQL `select`,
|
||||
filtering by `labels_expected` make sure we only return columns users want.
|
||||
|
||||
:param df: Original DataFrame returned by the engine
|
||||
:return: Mutated DataFrame
|
||||
"""
|
||||
labels_expected = query_str_ext.labels_expected
|
||||
if df is not None and not df.empty:
|
||||
if len(df.columns) != len(labels_expected):
|
||||
if len(df.columns) < len(labels_expected):
|
||||
raise QueryObjectValidationError(
|
||||
f"For {sql}, df.columns: {df.columns}"
|
||||
f" differs from {labels_expected}"
|
||||
_("Db engine did not return all queried columns")
|
||||
)
|
||||
if len(df.columns) > len(labels_expected):
|
||||
df = df.iloc[:, 0 : len(labels_expected)]
|
||||
df.columns = labels_expected
|
||||
return df
|
||||
|
||||
try:
|
||||
df = self.database.get_df(sql, self.schema, mutator)
|
||||
df = self.database.get_df(sql, self.schema, mutator=assign_column_label)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
df = pd.DataFrame()
|
||||
status = utils.QueryStatus.FAILED
|
||||
|
||||
@@ -232,7 +232,8 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
time_secondary_columns = False
|
||||
allows_joins = True
|
||||
allows_subqueries = True
|
||||
allows_column_aliases = True
|
||||
allows_alias_in_select = True
|
||||
allows_alias_in_orderby = True
|
||||
allows_sql_comments = True
|
||||
force_column_alias_quotes = False
|
||||
arraysize = 0
|
||||
|
||||
@@ -26,7 +26,8 @@ class PinotEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
|
||||
engine_name = "Apache Pinot"
|
||||
allows_subqueries = False
|
||||
allows_joins = False
|
||||
allows_column_aliases = False
|
||||
allows_alias_in_select = False
|
||||
allows_alias_in_orderby = False
|
||||
|
||||
# Pinot does its own conversion below
|
||||
_time_grain_expressions: Dict[Optional[str], str] = {
|
||||
|
||||
@@ -394,7 +394,7 @@ class Database(
|
||||
)
|
||||
df = result_set.to_pandas_df()
|
||||
if mutator:
|
||||
mutator(df)
|
||||
df = mutator(df)
|
||||
|
||||
for col, coltype in df.dtypes.to_dict().items():
|
||||
if coltype == numpy.object_ and needs_conversion(df[col]):
|
||||
|
||||
+3
-1
@@ -30,7 +30,9 @@ FilterValue = Union[float, int, str]
|
||||
FilterValues = Union[FilterValue, List[FilterValue], Tuple[FilterValue]]
|
||||
FormData = Dict[str, Any]
|
||||
Granularity = Union[str, Dict[str, Union[str, float]]]
|
||||
Metric = Union[Dict[str, str], str]
|
||||
AdhocMetric = Dict[str, Any]
|
||||
Metric = Union[AdhocMetric, str]
|
||||
OrderBy = Tuple[Metric, bool]
|
||||
QueryObjectDict = Dict[str, Any]
|
||||
VizData = Optional[Union[List[Any], Dict[Any, Any]]]
|
||||
VizPayload = Dict[str, Any]
|
||||
|
||||
@@ -1243,7 +1243,7 @@ def backend() -> str:
|
||||
|
||||
|
||||
def is_adhoc_metric(metric: Metric) -> bool:
|
||||
return isinstance(metric, dict)
|
||||
return isinstance(metric, dict) and "expressionType" in metric
|
||||
|
||||
|
||||
def get_metric_name(metric: Metric) -> str:
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
@@ -577,7 +578,7 @@ def contribution(
|
||||
:return: DataFrame with contributions.
|
||||
"""
|
||||
contribution_df = df.copy()
|
||||
numeric_df = contribution_df.select_dtypes(include="number")
|
||||
numeric_df = contribution_df.select_dtypes(include=["number", Decimal])
|
||||
# verify column selections
|
||||
if columns:
|
||||
numeric_columns = numeric_df.columns.tolist()
|
||||
|
||||
+4
-2
@@ -519,7 +519,8 @@ class BaseViz:
|
||||
+ (query_obj.get("groupby") or [])
|
||||
+ utils.get_column_names_from_metrics(
|
||||
cast(
|
||||
List[Union[str, Dict[str, Any]]], query_obj.get("metrics"),
|
||||
List[Union[str, Dict[str, Any]]],
|
||||
query_obj.get("metrics") or [],
|
||||
)
|
||||
)
|
||||
if col not in self.datasource.column_names
|
||||
@@ -1666,7 +1667,8 @@ class HistogramViz(BaseViz):
|
||||
self.columns = numeric_columns
|
||||
d["columns"] = numeric_columns + self.groupby
|
||||
# override groupby entry to avoid aggregation
|
||||
d["groupby"] = []
|
||||
d["groupby"] = None
|
||||
d["metrics"] = None
|
||||
return d
|
||||
|
||||
def labelify(self, keys: Union[List[str], str], column: str) -> str:
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ def _get_dataframe(database: Database) -> DataFrame:
|
||||
data = _get_birth_names_data()
|
||||
df = pd.DataFrame.from_dict(data)
|
||||
if database.backend == "presto":
|
||||
df.ds = df.ds.dt.strftime("%Y-%m-%d %H:%M%:%S")
|
||||
df.ds = df.ds.dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return df
|
||||
|
||||
|
||||
|
||||
@@ -421,7 +421,7 @@ class TestSqlaTableModel(SupersetTestCase):
|
||||
tbl = self.get_table_by_name("birth_names")
|
||||
query_obj = dict(
|
||||
groupby=[],
|
||||
metrics=[],
|
||||
metrics=None,
|
||||
filter=[],
|
||||
is_timeseries=False,
|
||||
columns=["name"],
|
||||
|
||||
+5
-12
@@ -15,19 +15,17 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# isort:skip_file
|
||||
import datetime
|
||||
import inspect
|
||||
import re
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pandas as pd
|
||||
from unittest.mock import Mock, patch
|
||||
from typing import Any, Dict
|
||||
|
||||
import prison
|
||||
import pytest
|
||||
import random
|
||||
|
||||
from flask import current_app, g
|
||||
from sqlalchemy import Float, Date, String
|
||||
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
@@ -42,11 +40,6 @@ from superset.sql_parse import Table
|
||||
from superset.utils.core import get_example_database
|
||||
|
||||
from .base_tests import SupersetTestCase
|
||||
from .dashboard_utils import (
|
||||
create_table_for_dashboard,
|
||||
create_slice,
|
||||
create_dashboard,
|
||||
)
|
||||
from tests.fixtures.birth_names_dashboard import load_birth_names_dashboard_with_slices
|
||||
from tests.fixtures.energy_dashboard import load_energy_table_with_slice
|
||||
from tests.fixtures.public_role import (
|
||||
@@ -1037,9 +1030,9 @@ class TestRowLevelSecurity(SupersetTestCase):
|
||||
"""
|
||||
|
||||
rls_entry = None
|
||||
query_obj = dict(
|
||||
query_obj: Dict[str, Any] = dict(
|
||||
groupby=[],
|
||||
metrics=[],
|
||||
metrics=None,
|
||||
filter=[],
|
||||
is_timeseries=False,
|
||||
columns=["value"],
|
||||
|
||||
Reference in New Issue
Block a user