mirror of
https://github.com/apache/superset.git
synced 2026-06-11 18:49:15 +00:00
Compare commits
5 Commits
feat/post-
...
oss-104290
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32a683f6f | ||
|
|
362b9faaab | ||
|
|
fdd97d42ad | ||
|
|
1d276e67b2 | ||
|
|
ed15be88d9 |
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -62,11 +62,6 @@ updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 10
|
||||
# Bump the lower bound to the new version, not just widen the upper
|
||||
# bound. Without this, a `sqlglot>=28.10.0, <29` constraint upgraded
|
||||
# to `<30` would keep the stale lower bound forever, dragging
|
||||
# transitively-resolved versions with it. See #40186 (review thread).
|
||||
versioning-strategy: increase
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
|
||||
791
superset-frontend/package-lock.json
generated
791
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -191,7 +191,7 @@
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"markdown-to-jsx": "^9.8.1",
|
||||
"markdown-to-jsx": "^9.8.0",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -350,7 +350,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"oxlint": "^1.65.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"acorn": "^8.16.0",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -97,7 +97,7 @@ export function createWrapper(options?: Options) {
|
||||
}
|
||||
|
||||
if (useDnd) {
|
||||
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
|
||||
// @ts-expect-error react-dnd types not updated for React 18
|
||||
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,267 +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.
|
||||
*/
|
||||
import { createRef } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
selectOption,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
import UIFilters from './index';
|
||||
import SelectFilter from './Select';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const mockUpdateFilterValue = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateFilterValue.mockClear();
|
||||
});
|
||||
|
||||
test('select filter with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Regression for sc-104554: the chart-list Owner filter renders options
|
||||
// with ReactNode labels (name + email). The value passed to
|
||||
// updateFilterValue is serialized into URL / filter state and re-used to
|
||||
// render the filter pill on return. It must carry the plain-text name
|
||||
// (from `title`) and not fall back to the numeric user id.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>John Doe</span>
|
||||
<span>john@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: ReactNodeLabel,
|
||||
value: 42,
|
||||
title: 'John Doe',
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('John Doe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'John Doe',
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('select filter falls back to stringified value when no string label or title is available', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: <span>123</span>,
|
||||
value: 123,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Something',
|
||||
key: 'something',
|
||||
id: 'something',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('123', 'Something');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: '123',
|
||||
value: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with string label passes label through unchanged', async () => {
|
||||
// Happy-path coverage for the typeof-string branch in onChange, exercised
|
||||
// through the non-async Select wrapper (selects array, no fetchSelects).
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Status',
|
||||
key: 'status',
|
||||
id: 'status',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.Equals,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: 'Published', value: 7 },
|
||||
{ label: 'Draft', value: 8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Published',
|
||||
value: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
|
||||
// the non-async Select wrapper. Guards against the two wrappers ever
|
||||
// diverging on antd's two-arg onChange shape.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>Jane Roe</span>
|
||||
<span>jane@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Jane Roe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Jane Roe',
|
||||
value: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
|
||||
// The isClear flag is what allows the parent (Filters/index) to suppress
|
||||
// onFilterUpdate side-effects when the user clears the filter rather than
|
||||
// picking a new value. Lock that contract in.
|
||||
const mockOnSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
|
||||
render(
|
||||
<SelectFilter
|
||||
Header="Owner"
|
||||
initialValue={{ label: 'John Doe', value: 42 }}
|
||||
onSelect={mockOnSelect}
|
||||
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
ref.current?.clearFilter();
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('rehydrates filter pill from initialValue with plain-string label', async () => {
|
||||
// The user-visible regression: after URL/state rehydration the filter pill
|
||||
// must render the human-readable name, not the numeric user id. The fix
|
||||
// ensures the persisted label is a string; this test asserts that string
|
||||
// is what surfaces in the rendered combobox selection.
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owners',
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
value: { label: 'John Doe', value: 42 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -58,22 +58,14 @@ function SelectFilter(
|
||||
) {
|
||||
const [selectedOption, setSelectedOption] = useState(initialValue);
|
||||
|
||||
const onChange = (selected: SelectOption, option?: SelectOption) => {
|
||||
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
|
||||
// labeled-value as the first arg and the full option (which carries
|
||||
// `title` and any other fields) as the second. Options may supply a
|
||||
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
|
||||
// filter). Since this object is serialized into the URL and rehydrated
|
||||
// as the filter pill on return, we need a plain string. Prefer `title`
|
||||
// (set by callers to the human-readable name) before falling back to
|
||||
// the value.
|
||||
const onChange = (selected: SelectOption) => {
|
||||
onSelect(
|
||||
selected
|
||||
? {
|
||||
label:
|
||||
typeof selected.label === 'string'
|
||||
? selected.label
|
||||
: (option?.title ?? String(selected.value)),
|
||||
: String(selected.value),
|
||||
value: selected.value,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -26,10 +26,6 @@ export interface SortColumn {
|
||||
export interface SelectOption {
|
||||
label: ReactNode;
|
||||
value: any;
|
||||
// Plain-text representation of the option. Callers should set this when
|
||||
// `label` is a ReactNode so that the option can be serialized (e.g. into
|
||||
// URL filter state) without losing the human-readable name.
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,6 @@ type LaunchQueue = {
|
||||
|
||||
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
|
||||
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
|
||||
const consumerPromises: Promise<void>[] = [];
|
||||
|
||||
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
|
||||
// the component's useEffect — calling it inline deadlocks Jest because the
|
||||
@@ -132,11 +131,7 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
|
||||
if (fileHandle) {
|
||||
const id = setTimeout(() => {
|
||||
pendingTimerIds.delete(id);
|
||||
consumerPromises.push(
|
||||
Promise.resolve(consumer({ files: [fileHandle] })).then(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
consumer({ files: [fileHandle] });
|
||||
}, 0);
|
||||
pendingTimerIds.add(id);
|
||||
}
|
||||
@@ -170,19 +165,9 @@ beforeEach(() => {
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
pendingTimerIds.forEach(id => clearTimeout(id));
|
||||
pendingTimerIds.clear();
|
||||
if (consumerPromises.length > 0) {
|
||||
const results = await Promise.allSettled(consumerPromises);
|
||||
results.forEach(r => {
|
||||
if (r.status === 'rejected') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('LaunchQueue consumer rejected:', r.reason);
|
||||
}
|
||||
});
|
||||
consumerPromises.length = 0;
|
||||
}
|
||||
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
@@ -22,15 +22,7 @@ from typing import Any, TYPE_CHECKING
|
||||
|
||||
from flask import current_app
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import (
|
||||
EXCLUDE,
|
||||
fields,
|
||||
post_load,
|
||||
Schema,
|
||||
validate,
|
||||
validates_schema,
|
||||
ValidationError,
|
||||
)
|
||||
from marshmallow import EXCLUDE, fields, post_load, Schema, validate
|
||||
from marshmallow.validate import Length, Range
|
||||
from marshmallow_union import Union
|
||||
|
||||
@@ -945,42 +937,6 @@ class ChartDataPostProcessingOperationSchema(Schema):
|
||||
},
|
||||
)
|
||||
|
||||
# Map post-processing operation -> its options schema, for operations that
|
||||
# declare one. Operations without a dedicated schema are not structurally
|
||||
# validated here.
|
||||
_OPTIONS_SCHEMAS: dict[str, type[Schema]] = {
|
||||
"aggregate": ChartDataAggregateOptionsSchema,
|
||||
"rolling": ChartDataRollingOptionsSchema,
|
||||
"select": ChartDataSelectOptionsSchema,
|
||||
"sort": ChartDataSortOptionsSchema,
|
||||
"contribution": ChartDataContributionOptionsSchema,
|
||||
"prophet": ChartDataProphetOptionsSchema,
|
||||
"boxplot": ChartDataBoxplotOptionsSchema,
|
||||
"pivot": ChartDataPivotOptionsSchema,
|
||||
"geohash_decode": ChartDataGeohashDecodeOptionsSchema,
|
||||
"geohash_encode": ChartDataGeohashEncodeOptionsSchema,
|
||||
"geodetic_parse": ChartDataGeodeticParseOptionsSchema,
|
||||
}
|
||||
|
||||
@validates_schema
|
||||
def validate_options(self, data: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""Validate ``options`` against the operation's option schema.
|
||||
|
||||
Validation is lenient (unknown keys are ignored) so it surfaces wrong
|
||||
types / out-of-range values on declared fields without rejecting
|
||||
payloads that carry extra keys.
|
||||
"""
|
||||
operation = data.get("operation")
|
||||
options = data.get("options")
|
||||
if not isinstance(operation, str) or not isinstance(options, dict):
|
||||
return
|
||||
schema_cls = self._OPTIONS_SCHEMAS.get(operation)
|
||||
if schema_cls is None:
|
||||
return
|
||||
errors = schema_cls(unknown=EXCLUDE).validate(options)
|
||||
if errors:
|
||||
raise ValidationError({"options": errors})
|
||||
|
||||
|
||||
class ChartDataFilterSchema(Schema):
|
||||
col = fields.Raw(
|
||||
|
||||
@@ -21,11 +21,10 @@ import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from typing import Any, Callable, Optional, TYPE_CHECKING
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
from flask_babel import gettext as __
|
||||
from sqlalchemy import types
|
||||
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
|
||||
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
|
||||
from sqlalchemy.dialects.postgresql.base import PGInspector
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
@@ -136,34 +135,6 @@ def parse_options(connect_args: dict[str, Any]) -> dict[str, str]:
|
||||
return {token[0]: token[1] for token in tokens}
|
||||
|
||||
|
||||
def _normalize_interval(v: Any) -> Optional[float]:
|
||||
"""Convert PostgreSQL INTERVAL values to milliseconds.
|
||||
|
||||
psycopg2 and psycopg3 always return INTERVAL values as datetime.timedelta
|
||||
objects. We convert to milliseconds so users can apply the built-in
|
||||
"DURATION" number format for human-readable display (e.g.,
|
||||
"1d 2h 30m 45s") and so the values participate cleanly in numeric
|
||||
aggregations in bar/pie charts.
|
||||
|
||||
Returns None for the NULL case (preserves NULL semantics) and for any
|
||||
unexpected non-timedelta type (avoids producing a mixed-type column
|
||||
when an unfamiliar driver surfaces something other than timedelta).
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
if hasattr(v, "total_seconds"):
|
||||
return v.total_seconds() * 1000
|
||||
# Defensive: psycopg2/3 should always hand us a timedelta. If a future
|
||||
# driver doesn't, surface the surprise in the logs rather than silently
|
||||
# dropping the value so operators can diagnose it.
|
||||
logger.warning(
|
||||
"Cannot normalize PostgreSQL INTERVAL value of type %s to numeric; "
|
||||
"returning None.",
|
||||
type(v).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PostgresBaseEngineSpec(BaseEngineSpec):
|
||||
"""Abstract class for Postgres 'like' databases"""
|
||||
|
||||
@@ -555,17 +526,8 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
|
||||
ENUM(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
(
|
||||
re.compile(r"^interval", re.IGNORECASE),
|
||||
INTERVAL(),
|
||||
GenericDataType.NUMERIC,
|
||||
),
|
||||
)
|
||||
|
||||
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
|
||||
INTERVAL: _normalize_interval,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_schema_from_engine_params(
|
||||
cls,
|
||||
|
||||
@@ -260,6 +260,140 @@ def merge_extra_form_data_filters_into_query(
|
||||
merge_form_data_filters_into_query(query, extra_query_form_data)
|
||||
|
||||
|
||||
def _deck_gl_spatial_cols(spatial: dict[str, Any] | None) -> list[str]:
|
||||
"""Return the column names referenced by a single Deck.gl spatial control."""
|
||||
if not isinstance(spatial, dict):
|
||||
return []
|
||||
spatial_type = spatial.get("type")
|
||||
if spatial_type == "latlong":
|
||||
return [c for c in [spatial.get("lonCol"), spatial.get("latCol")] if c]
|
||||
if spatial_type == "delimited":
|
||||
return [c for c in [spatial.get("lonlatCol")] if c]
|
||||
if spatial_type == "geohash":
|
||||
return [c for c in [spatial.get("geohashCol")] if c]
|
||||
return []
|
||||
|
||||
|
||||
def _deck_gl_tooltip_cols(tooltip_contents: list[Any] | None) -> list[str]:
|
||||
"""Return column names from Deck.gl tooltip_contents config."""
|
||||
cols: list[str] = []
|
||||
for item in tooltip_contents or []:
|
||||
if isinstance(item, str):
|
||||
cols.append(item)
|
||||
elif isinstance(item, dict) and item.get("item_type") == "column":
|
||||
col = item.get("column_name")
|
||||
if isinstance(col, str) and col:
|
||||
cols.append(col)
|
||||
return cols
|
||||
|
||||
|
||||
def _is_metric_ref(value: Any) -> bool:
|
||||
"""Return True if value is a metric reference (dict or non-numeric string).
|
||||
|
||||
Deck.gl size/metric fields hold either a dict metric definition or a
|
||||
simple saved-metric string key (e.g. "count"). Scalar numeric strings
|
||||
like "100" are fixed display settings and must not be treated as metrics.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return True
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
float(value)
|
||||
return False
|
||||
except ValueError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _deck_gl_null_filters(form_data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Build IS NOT NULL simple filters for Deck.gl spatial and data columns.
|
||||
|
||||
Mirrors BaseDeckGLViz.add_null_filters() behavior: spatial control columns,
|
||||
line_column, and the geojson column are filtered for non-null values by
|
||||
default.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for key in ("spatial", "start_spatial", "end_spatial"):
|
||||
for col in _deck_gl_spatial_cols(form_data.get(key)):
|
||||
if col not in seen:
|
||||
seen.add(col)
|
||||
result.append({"col": col, "op": "IS NOT NULL", "val": ""})
|
||||
for field in ("line_column", "geojson"):
|
||||
data_col = form_data.get(field)
|
||||
if isinstance(data_col, str) and data_col and data_col not in seen:
|
||||
seen.add(data_col)
|
||||
result.append({"col": data_col, "op": "IS NOT NULL", "val": ""})
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_deck_gl_metrics(
|
||||
form_data: dict[str, Any], viz_type: str = ""
|
||||
) -> list[Any]:
|
||||
"""Extract metrics for Deck.gl chart types.
|
||||
|
||||
deck_geojson.query_obj() forces metrics=[] regardless of form_data.
|
||||
For other types, size/metric values are included when they are metric
|
||||
references (dicts or non-numeric strings); numeric scalars like "100"
|
||||
are fixed display settings and are excluded.
|
||||
deck_scatter and deck_polygon can additionally store metric-backed
|
||||
values in point_radius_fixed (radius for scatter, elevation for polygon).
|
||||
"""
|
||||
if viz_type == "deck_geojson":
|
||||
return []
|
||||
metrics: list[Any] = []
|
||||
for field in ("size", "metric"):
|
||||
m = form_data.get(field)
|
||||
if _is_metric_ref(m):
|
||||
metrics.append(m)
|
||||
prf = form_data.get("point_radius_fixed")
|
||||
if isinstance(prf, dict) and prf.get("type") == "metric":
|
||||
value = prf.get("value")
|
||||
if value:
|
||||
metrics.append(value)
|
||||
elif _is_metric_ref(prf):
|
||||
# Legacy deck_scatter: point_radius_fixed can be a bare metric key string
|
||||
metrics.append(prf)
|
||||
return metrics
|
||||
|
||||
|
||||
def resolve_deck_gl_columns(form_data: dict[str, Any]) -> list[str]:
|
||||
"""Extract SQL column names for Deck.gl chart types from form_data.
|
||||
|
||||
Deck.gl charts use spatial controls (lat/lon pairs, geohash, etc.)
|
||||
rather than the standard metrics/groupby structure. This function
|
||||
maps those spatial control configs to the actual column names
|
||||
needed by the SQL query.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
columns: list[str] = []
|
||||
|
||||
def _add(col: str | None) -> None:
|
||||
if col and isinstance(col, str) and col not in seen:
|
||||
seen.add(col)
|
||||
columns.append(col)
|
||||
|
||||
# Most Deck.gl types use "spatial"; arc charts use start/end spatial
|
||||
for key in ("spatial", "start_spatial", "end_spatial"):
|
||||
for col in _deck_gl_spatial_cols(form_data.get(key)):
|
||||
_add(col)
|
||||
|
||||
# deck_path / deck_polygon use a line column; deck_geojson uses geojson
|
||||
for field in ("line_column", "geojson", "dimension"):
|
||||
_add(form_data.get(field))
|
||||
|
||||
for col in form_data.get("js_columns") or []:
|
||||
if isinstance(col, str):
|
||||
_add(col)
|
||||
|
||||
for col in _deck_gl_tooltip_cols(form_data.get("tooltip_contents")):
|
||||
_add(col)
|
||||
|
||||
_add(form_data.get("cross_filter_column"))
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def resolve_metrics(form_data: dict[str, Any], viz_type: str) -> list[Any]:
|
||||
"""Extract metrics from form_data, handling chart-type-specific fields."""
|
||||
if viz_type == "bubble":
|
||||
@@ -423,6 +557,25 @@ def build_query_dicts_from_form_data(
|
||||
or (getattr(chart, "viz_type", "") if chart else "")
|
||||
or ""
|
||||
)
|
||||
|
||||
# Deck.gl charts use spatial column configs rather than the standard
|
||||
# metrics / groupby fields. Extract columns from the spatial controls.
|
||||
if viz_type.startswith("deck_"):
|
||||
deck_columns = resolve_deck_gl_columns(form_data)
|
||||
deck_metrics = _resolve_deck_gl_metrics(form_data, viz_type)
|
||||
qd = _build_single_query_dict(
|
||||
form_data,
|
||||
deck_columns,
|
||||
deck_metrics,
|
||||
row_limit=row_limit,
|
||||
order_desc=order_desc,
|
||||
)
|
||||
if form_data.get("filter_nulls", True):
|
||||
null_filters = _deck_gl_null_filters(form_data)
|
||||
if null_filters:
|
||||
qd["filters"] = [*(qd.get("filters") or []), *null_filters]
|
||||
return [qd]
|
||||
|
||||
is_timeseries = (
|
||||
viz_type.startswith("echarts_timeseries") or viz_type == "mixed_timeseries"
|
||||
)
|
||||
|
||||
@@ -340,31 +340,10 @@ async def get_chart_data( # noqa: C901
|
||||
# groupby-like fields (entity, series, columns):
|
||||
# world_map, treemap_v2, sunburst_v2, gauge_chart
|
||||
# Bubble charts use x/y/size as separate metric fields.
|
||||
# Deck.gl charts (deck_arc, deck_scatter, etc.) use spatial
|
||||
# column configs (lat/lon, geohash, etc.) instead.
|
||||
viz_type = chart.viz_type or ""
|
||||
|
||||
# Deck.gl chart types store spatial data (lat/lon)
|
||||
# rather than traditional metrics/groupby. They
|
||||
# require a saved query_context to retrieve data.
|
||||
# Match by prefix to cover all current and future
|
||||
# deck.gl viz types (deck_arc, deck_scatter, etc.).
|
||||
if viz_type.startswith("deck_"):
|
||||
await ctx.warning(
|
||||
"Chart %s is a deck.gl visualization (%s) with no "
|
||||
"saved query_context. Data retrieval requires "
|
||||
"re-saving the chart in Superset." % (chart.id, viz_type)
|
||||
)
|
||||
return ChartError(
|
||||
error=(
|
||||
f"Chart {chart.id} is a deck.gl visualization "
|
||||
f"(type: {viz_type}) with no saved query_context. "
|
||||
f"Deck.gl charts use spatial data (lat/lon) that "
|
||||
f"cannot be reconstructed from form_data alone. "
|
||||
f"Please open this chart in Superset and re-save "
|
||||
f"it to generate a query_context."
|
||||
),
|
||||
error_type="MissingQueryContext",
|
||||
)
|
||||
|
||||
fallback_queries = build_query_dicts_from_form_data(
|
||||
form_data,
|
||||
chart.datasource_id,
|
||||
|
||||
@@ -53,11 +53,7 @@ from superset_core.queries.models import (
|
||||
)
|
||||
|
||||
from superset import security_manager
|
||||
from superset.exceptions import (
|
||||
SupersetException,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
)
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.explorables.base import TimeGrainDict
|
||||
from superset.jinja_context import BaseTemplateProcessor, get_template_processor
|
||||
from superset.models.helpers import (
|
||||
@@ -103,14 +99,6 @@ class SqlTablesMixin: # pylint: disable=too-few-public-methods
|
||||
)
|
||||
except (SupersetSecurityException, SupersetParseError, TemplateError):
|
||||
return []
|
||||
except SupersetException as ex:
|
||||
# Jinja macros such as ``{{ dataset(id) }}`` or ``{{ metric(...) }}``
|
||||
# may reference resources that no longer exist (e.g. a deleted
|
||||
# dataset). Surfacing the failure here would break list endpoints
|
||||
# that include ``sql_tables`` in their payload, hiding every saved
|
||||
# query from the user. Treat it as a parse failure instead.
|
||||
logger.warning("Unable to extract tables from SQL via Jinja: %s", ex)
|
||||
return []
|
||||
|
||||
|
||||
class Query(
|
||||
|
||||
@@ -152,53 +152,3 @@ def test_time_grain_validation_with_config_addons(app_context: None) -> None:
|
||||
}
|
||||
result = schema.load(custom_data)
|
||||
assert result["time_grain"] == "PT10M"
|
||||
|
||||
|
||||
def test_post_processing_operation_validates_options(app_context: None) -> None:
|
||||
"""options are validated against the operation's option schema (leniently)."""
|
||||
from superset.charts.schemas import ChartDataPostProcessingOperationSchema
|
||||
|
||||
schema = ChartDataPostProcessingOperationSchema()
|
||||
|
||||
# Valid prophet options load.
|
||||
schema.load(
|
||||
{
|
||||
"operation": "prophet",
|
||||
"options": {
|
||||
"time_grain": "P1D",
|
||||
"periods": 7,
|
||||
"confidence_interval": 0.8,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Out-of-range confidence_interval (must be 0-1) on a declared field is
|
||||
# rejected.
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load(
|
||||
{
|
||||
"operation": "prophet",
|
||||
"options": {
|
||||
"time_grain": "P1D",
|
||||
"periods": 7,
|
||||
"confidence_interval": 2.0,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert "options" in exc_info.value.messages
|
||||
|
||||
# Extra/unknown keys are tolerated (lenient validation).
|
||||
schema.load(
|
||||
{
|
||||
"operation": "prophet",
|
||||
"options": {
|
||||
"time_grain": "P1D",
|
||||
"periods": 7,
|
||||
"confidence_interval": 0.8,
|
||||
"some_future_option": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# An operation without a dedicated schema accepts arbitrary options.
|
||||
schema.load({"operation": "flatten", "options": {"anything": [1, 2, 3]}})
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy import column, types
|
||||
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
|
||||
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
|
||||
from sqlalchemy.engine.interfaces import Dialect
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
@@ -87,8 +87,6 @@ def test_convert_dttm(
|
||||
("TIME", types.Time, None, GenericDataType.TEMPORAL, True),
|
||||
# Boolean
|
||||
("BOOLEAN", types.Boolean, None, GenericDataType.BOOLEAN, False),
|
||||
# Interval (mapped to NUMERIC for chart rendering)
|
||||
("INTERVAL", INTERVAL, None, GenericDataType.NUMERIC, False),
|
||||
],
|
||||
)
|
||||
def test_get_column_spec(
|
||||
@@ -368,38 +366,3 @@ class TestRedshiftDetection:
|
||||
spec.update_params_from_encrypted_extra(database, params)
|
||||
|
||||
assert "pool_events" not in params
|
||||
|
||||
|
||||
def test_interval_type_mutator() -> None:
|
||||
"""
|
||||
DB Eng Specs (postgres): Test INTERVAL type mutator
|
||||
|
||||
INTERVAL values are converted to milliseconds so users can apply
|
||||
the built-in "DURATION" number format for human-readable display.
|
||||
"""
|
||||
mutator = spec.column_type_mutators[INTERVAL]
|
||||
|
||||
# Timedelta conversion — the only path psycopg2/psycopg3 actually
|
||||
# exercises. Result is in milliseconds for compatibility with the
|
||||
# DURATION formatter.
|
||||
td = timedelta(days=1, hours=2, minutes=30, seconds=45)
|
||||
assert mutator(td) == 95445000.0 # (1*86400 + 2*3600 + 30*60 + 45) * 1000
|
||||
|
||||
# Zero duration
|
||||
assert mutator(timedelta(0)) == 0.0
|
||||
|
||||
# Negative interval
|
||||
assert mutator(timedelta(days=-1)) == -86400000.0
|
||||
|
||||
# None preserves NULL semantics (not converted to 0)
|
||||
assert mutator(None) is None
|
||||
|
||||
# Unexpected non-timedelta types fall through to the defensive
|
||||
# `return None` (and emit a warning) rather than producing a
|
||||
# mixed-type column.
|
||||
assert mutator("1 day 02:30:45") is None
|
||||
assert mutator("P1DT2H30M45S") is None
|
||||
assert mutator(12345) is None
|
||||
assert mutator(True) is None
|
||||
assert mutator([1, 2, 3]) is None
|
||||
assert mutator({"days": 1}) is None
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from superset.mcp_service.chart.chart_helpers import (
|
||||
_deck_gl_null_filters,
|
||||
_deck_gl_tooltip_cols,
|
||||
_is_metric_ref,
|
||||
_resolve_deck_gl_metrics,
|
||||
apply_form_data_filters_to_query,
|
||||
build_query_dicts_from_form_data,
|
||||
extract_form_data_key_from_url,
|
||||
@@ -26,6 +30,7 @@ from superset.mcp_service.chart.chart_helpers import (
|
||||
merge_extra_form_data_filters_into_query,
|
||||
merge_form_data_filters_into_query,
|
||||
prepare_form_data_for_query,
|
||||
resolve_deck_gl_columns,
|
||||
)
|
||||
|
||||
|
||||
@@ -285,3 +290,601 @@ def test_merge_extra_form_data_filters_into_query_adds_only_extra_predicates(
|
||||
assert query["time_range"] == "No filter"
|
||||
assert query["granularity"] == "updated_at"
|
||||
assert query["time_grain_sqla"] == "P1D"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_deck_gl_columns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_latlong():
|
||||
form_data = {
|
||||
"spatial": {"type": "latlong", "lonCol": "longitude", "latCol": "latitude"},
|
||||
}
|
||||
assert resolve_deck_gl_columns(form_data) == ["longitude", "latitude"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_delimited():
|
||||
form_data = {
|
||||
"spatial": {"type": "delimited", "lonlatCol": "coords"},
|
||||
}
|
||||
assert resolve_deck_gl_columns(form_data) == ["coords"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_geohash():
|
||||
form_data = {
|
||||
"spatial": {"type": "geohash", "geohashCol": "geo"},
|
||||
}
|
||||
assert resolve_deck_gl_columns(form_data) == ["geo"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_arc_start_end():
|
||||
form_data = {
|
||||
"start_spatial": {
|
||||
"type": "latlong",
|
||||
"lonCol": "start_lon",
|
||||
"latCol": "start_lat",
|
||||
},
|
||||
"end_spatial": {"type": "latlong", "lonCol": "end_lon", "latCol": "end_lat"},
|
||||
}
|
||||
cols = resolve_deck_gl_columns(form_data)
|
||||
assert cols == ["start_lon", "start_lat", "end_lon", "end_lat"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_path_line_column():
|
||||
form_data = {
|
||||
"line_column": "path_wkt",
|
||||
}
|
||||
assert resolve_deck_gl_columns(form_data) == ["path_wkt"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_geojson():
|
||||
form_data = {
|
||||
"geojson": "geom_col",
|
||||
}
|
||||
assert resolve_deck_gl_columns(form_data) == ["geom_col"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_with_dimension_and_js_columns():
|
||||
form_data = {
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"dimension": "category",
|
||||
"js_columns": ["name", "value"],
|
||||
}
|
||||
cols = resolve_deck_gl_columns(form_data)
|
||||
assert "lon" in cols
|
||||
assert "lat" in cols
|
||||
assert "category" in cols
|
||||
assert "name" in cols
|
||||
assert "value" in cols
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_deduplicates():
|
||||
form_data = {
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"dimension": "lon", # same as lonCol — should not duplicate
|
||||
}
|
||||
cols = resolve_deck_gl_columns(form_data)
|
||||
assert cols.count("lon") == 1
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_empty():
|
||||
assert resolve_deck_gl_columns({}) == []
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_ignores_non_string_js_columns():
|
||||
form_data = {
|
||||
"js_columns": [42, None, "valid_col"],
|
||||
}
|
||||
assert resolve_deck_gl_columns(form_data) == ["valid_col"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_query_dicts_from_form_data — Deck.gl branch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_scatter_latlong(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_scatter",
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert len(queries) == 1
|
||||
assert queries[0]["columns"] == ["lon", "lat"]
|
||||
assert queries[0]["metrics"] == []
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_scatter_with_size_metric(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
metric = {
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {"column_name": "sales"},
|
||||
"aggregate": "SUM",
|
||||
}
|
||||
form_data = {
|
||||
"viz_type": "deck_scatter",
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"size": metric,
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert len(queries) == 1
|
||||
assert queries[0]["columns"] == ["lon", "lat"]
|
||||
assert queries[0]["metrics"] == [metric]
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_arc(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_arc",
|
||||
"start_spatial": {
|
||||
"type": "latlong",
|
||||
"lonCol": "origin_lon",
|
||||
"latCol": "origin_lat",
|
||||
},
|
||||
"end_spatial": {"type": "latlong", "lonCol": "dest_lon", "latCol": "dest_lat"},
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert len(queries) == 1
|
||||
assert queries[0]["columns"] == ["origin_lon", "origin_lat", "dest_lon", "dest_lat"]
|
||||
assert queries[0]["metrics"] == []
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_geojson(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_geojson",
|
||||
"geojson": "geometry",
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert len(queries) == 1
|
||||
assert queries[0]["columns"] == ["geometry"]
|
||||
assert queries[0]["metrics"] == []
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_hex_geohash(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_hex",
|
||||
"spatial": {"type": "geohash", "geohashCol": "geohash"},
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert len(queries) == 1
|
||||
assert queries[0]["columns"] == ["geohash"]
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_path_with_row_limit(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_path",
|
||||
"line_column": "path_col",
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table", row_limit=50)
|
||||
|
||||
assert queries[0]["columns"] == ["path_col"]
|
||||
assert queries[0]["row_limit"] == 50
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_deck_gl_columns — tooltip_contents and cross_filter_column (Fix 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_with_tooltip_contents_strings():
|
||||
form_data = {
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"tooltip_contents": ["name", "category"],
|
||||
}
|
||||
cols = resolve_deck_gl_columns(form_data)
|
||||
assert "name" in cols
|
||||
assert "category" in cols
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_with_tooltip_contents_dict_items():
|
||||
form_data = {
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"tooltip_contents": [
|
||||
{"item_type": "column", "column_name": "city"},
|
||||
{"item_type": "metric", "key": "sum__sales"}, # metric items ignored
|
||||
],
|
||||
}
|
||||
cols = resolve_deck_gl_columns(form_data)
|
||||
assert "city" in cols
|
||||
assert "sum__sales" not in cols
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_with_cross_filter_column():
|
||||
form_data = {
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"cross_filter_column": "region",
|
||||
}
|
||||
cols = resolve_deck_gl_columns(form_data)
|
||||
assert "region" in cols
|
||||
|
||||
|
||||
def test_resolve_deck_gl_columns_tooltip_deduplicates_with_spatial():
|
||||
form_data = {
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"tooltip_contents": ["lon"], # already in spatial cols
|
||||
}
|
||||
cols = resolve_deck_gl_columns(form_data)
|
||||
assert cols.count("lon") == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _deck_gl_tooltip_cols
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_deck_gl_tooltip_cols_strings():
|
||||
assert _deck_gl_tooltip_cols(["city", "state"]) == ["city", "state"]
|
||||
|
||||
|
||||
def test_deck_gl_tooltip_cols_dict_column_items():
|
||||
result = _deck_gl_tooltip_cols([{"item_type": "column", "column_name": "country"}])
|
||||
assert result == ["country"]
|
||||
|
||||
|
||||
def test_deck_gl_tooltip_cols_skips_metric_items():
|
||||
result = _deck_gl_tooltip_cols([{"item_type": "metric", "key": "sum__sales"}])
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_deck_gl_tooltip_cols_none():
|
||||
assert _deck_gl_tooltip_cols(None) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_metric_ref
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_metric_ref_dict():
|
||||
assert _is_metric_ref({"expressionType": "SIMPLE"}) is True
|
||||
|
||||
|
||||
def test_is_metric_ref_string_key():
|
||||
assert _is_metric_ref("count") is True
|
||||
assert _is_metric_ref("sum__sales") is True
|
||||
|
||||
|
||||
def test_is_metric_ref_numeric_string_excluded():
|
||||
assert _is_metric_ref("100") is False
|
||||
assert _is_metric_ref("3.14") is False
|
||||
assert _is_metric_ref("0") is False
|
||||
|
||||
|
||||
def test_is_metric_ref_integer_excluded():
|
||||
assert _is_metric_ref(100) is False
|
||||
|
||||
|
||||
def test_is_metric_ref_none_and_empty():
|
||||
assert _is_metric_ref(None) is False
|
||||
assert _is_metric_ref("") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_deck_gl_metrics (Fix 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_no_metrics():
|
||||
assert _resolve_deck_gl_metrics({}) == []
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_size_field():
|
||||
metric = {"expressionType": "SIMPLE", "aggregate": "COUNT", "column": None}
|
||||
result = _resolve_deck_gl_metrics({"size": metric})
|
||||
assert result == [metric]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_metric_field():
|
||||
metric = {"expressionType": "SIMPLE", "aggregate": "SUM"}
|
||||
result = _resolve_deck_gl_metrics({"metric": metric})
|
||||
assert result == [metric]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_point_radius_fixed_metric():
|
||||
prf_metric = {"expressionType": "SIMPLE", "aggregate": "AVG"}
|
||||
prf = {"type": "metric", "value": prf_metric}
|
||||
result = _resolve_deck_gl_metrics({"point_radius_fixed": prf})
|
||||
assert result == [prf_metric]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_point_radius_fixed_not_metric():
|
||||
prf = {"type": "fix", "value": 100}
|
||||
result = _resolve_deck_gl_metrics({"point_radius_fixed": prf})
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_polygon_both_metric_and_prf():
|
||||
base_metric = {"expressionType": "SIMPLE", "aggregate": "SUM"}
|
||||
elevation_metric = {"expressionType": "SIMPLE", "aggregate": "AVG"}
|
||||
prf = {"type": "metric", "value": elevation_metric}
|
||||
result = _resolve_deck_gl_metrics(
|
||||
{"metric": base_metric, "point_radius_fixed": prf}
|
||||
)
|
||||
assert result == [base_metric, elevation_metric]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_geojson_returns_empty():
|
||||
# deck_geojson.query_obj() forces metrics=[] regardless of form_data
|
||||
metric = {"expressionType": "SIMPLE", "aggregate": "SUM"}
|
||||
result = _resolve_deck_gl_metrics(
|
||||
{"size": metric, "metric": metric}, "deck_geojson"
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_scalar_size_excluded():
|
||||
# Numeric string size values (fixed display settings) must not be metrics
|
||||
result = _resolve_deck_gl_metrics({"size": "100"}, "deck_hex")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_integer_size_excluded():
|
||||
result = _resolve_deck_gl_metrics({"size": 100}, "deck_path")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_string_metric_included():
|
||||
# Non-numeric string metrics (saved metric keys) must be preserved
|
||||
result = _resolve_deck_gl_metrics({"size": "count"}, "deck_hex")
|
||||
assert result == ["count"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_string_metric_field():
|
||||
result = _resolve_deck_gl_metrics({"metric": "sum__sales"}, "deck_arc")
|
||||
assert result == ["sum__sales"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_string_point_radius_fixed():
|
||||
# Legacy deck_scatter: point_radius_fixed as a bare metric key string
|
||||
result = _resolve_deck_gl_metrics({"point_radius_fixed": "count"}, "deck_scatter")
|
||||
assert result == ["count"]
|
||||
|
||||
|
||||
def test_resolve_deck_gl_metrics_numeric_point_radius_fixed_excluded():
|
||||
# Numeric string point_radius_fixed is a fixed pixel radius, not a metric
|
||||
result = _resolve_deck_gl_metrics({"point_radius_fixed": "100"}, "deck_scatter")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _deck_gl_null_filters (Fix 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_deck_gl_null_filters_latlong():
|
||||
form_data = {
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
}
|
||||
result = _deck_gl_null_filters(form_data)
|
||||
assert result == [
|
||||
{"col": "lon", "op": "IS NOT NULL", "val": ""},
|
||||
{"col": "lat", "op": "IS NOT NULL", "val": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_deck_gl_null_filters_arc_start_end():
|
||||
form_data = {
|
||||
"start_spatial": {"type": "latlong", "lonCol": "s_lon", "latCol": "s_lat"},
|
||||
"end_spatial": {"type": "latlong", "lonCol": "e_lon", "latCol": "e_lat"},
|
||||
}
|
||||
result = _deck_gl_null_filters(form_data)
|
||||
assert result == [
|
||||
{"col": "s_lon", "op": "IS NOT NULL", "val": ""},
|
||||
{"col": "s_lat", "op": "IS NOT NULL", "val": ""},
|
||||
{"col": "e_lon", "op": "IS NOT NULL", "val": ""},
|
||||
{"col": "e_lat", "op": "IS NOT NULL", "val": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_deck_gl_null_filters_line_column():
|
||||
form_data = {"line_column": "path_col"}
|
||||
result = _deck_gl_null_filters(form_data)
|
||||
assert result == [{"col": "path_col", "op": "IS NOT NULL", "val": ""}]
|
||||
|
||||
|
||||
def test_deck_gl_null_filters_empty():
|
||||
assert _deck_gl_null_filters({}) == []
|
||||
|
||||
|
||||
def test_deck_gl_null_filters_geojson_column():
|
||||
# geojson column gets an IS NOT NULL filter just like spatial columns
|
||||
form_data = {"geojson": "geometry"}
|
||||
assert _deck_gl_null_filters(form_data) == [
|
||||
{"col": "geometry", "op": "IS NOT NULL", "val": ""}
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_query_dicts_from_form_data — null filters behavior (Fix 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_scatter_adds_null_filters_by_default(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_scatter",
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert {"col": "lon", "op": "IS NOT NULL", "val": ""} in queries[0]["filters"]
|
||||
assert {"col": "lat", "op": "IS NOT NULL", "val": ""} in queries[0]["filters"]
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_scatter_filter_nulls_false(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_scatter",
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"filter_nulls": False,
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
null_filters = [
|
||||
f for f in queries[0].get("filters", []) if f.get("op") == "IS NOT NULL"
|
||||
]
|
||||
assert null_filters == []
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_scatter_point_radius_fixed_metric(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
radius_metric = {
|
||||
"expressionType": "SIMPLE",
|
||||
"aggregate": "AVG",
|
||||
"column": {"column_name": "radius"},
|
||||
}
|
||||
form_data = {
|
||||
"viz_type": "deck_scatter",
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"point_radius_fixed": {"type": "metric", "value": radius_metric},
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert queries[0]["metrics"] == [radius_metric]
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_geojson_scalar_size_produces_no_metrics(monkeypatch):
|
||||
# Regression: deck_geojson fixture has size='100' (scalar, not a metric).
|
||||
# The fallback must produce metrics=[] to match DeckGeoJson.query_obj().
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_geojson",
|
||||
"geojson": "geometry",
|
||||
"size": "100",
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert queries[0]["metrics"] == []
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_path_scalar_size_produces_no_metrics(monkeypatch):
|
||||
# deck_path fixture also has size='100' — scalar must not become a metric.
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_path",
|
||||
"line_column": "path_col",
|
||||
"size": "100",
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert queries[0]["metrics"] == []
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_geojson_adds_geojson_null_filter(monkeypatch):
|
||||
# deck_geojson should add IS NOT NULL on the geojson column when filter_nulls
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_geojson",
|
||||
"geojson": "geometry_col",
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert {"col": "geometry_col", "op": "IS NOT NULL", "val": ""} in queries[0][
|
||||
"filters"
|
||||
]
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_hex_string_metric(monkeypatch):
|
||||
# Non-numeric string size (saved metric key) must be included as a metric
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_hex",
|
||||
"spatial": {"type": "geohash", "geohashCol": "geo"},
|
||||
"size": "count",
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert queries[0]["metrics"] == ["count"]
|
||||
|
||||
|
||||
def test_build_query_dicts_deck_scatter_string_point_radius_fixed(monkeypatch):
|
||||
# Legacy deck_scatter with point_radius_fixed as a bare metric key string
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda datasource_id, datasource_type: "base",
|
||||
)
|
||||
form_data = {
|
||||
"viz_type": "deck_scatter",
|
||||
"spatial": {"type": "latlong", "lonCol": "lon", "latCol": "lat"},
|
||||
"point_radius_fixed": "count",
|
||||
"adhoc_filters": [],
|
||||
}
|
||||
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
|
||||
assert queries[0]["metrics"] == ["count"]
|
||||
|
||||
@@ -21,14 +21,8 @@ from flask_appbuilder import Model
|
||||
from jinja2.exceptions import TemplateError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.dataset.exceptions import DatasetNotFoundError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
SupersetTemplateException,
|
||||
)
|
||||
from superset.models import sql_lab as sql_lab_module
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.models.sql_lab import Query, SavedQuery
|
||||
|
||||
|
||||
@@ -40,61 +34,34 @@ from superset.models.sql_lab import Query, SavedQuery
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "should_warn"),
|
||||
"exception",
|
||||
[
|
||||
# Original silent handler — security/parse/template errors are
|
||||
# expected during list rendering and produce no log noise.
|
||||
(
|
||||
SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
|
||||
message="",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
),
|
||||
False,
|
||||
SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
|
||||
message="",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
),
|
||||
(
|
||||
SupersetParseError(
|
||||
sql="INVALID SQL",
|
||||
message="Invalid SQL syntax",
|
||||
),
|
||||
False,
|
||||
SupersetParseError(
|
||||
sql="INVALID SQL",
|
||||
message="Invalid SQL syntax",
|
||||
),
|
||||
(TemplateError, False),
|
||||
# ``{{ dataset(id) }}`` referencing a deleted dataset previously
|
||||
# bubbled up through ``sql_tables`` and broke saved-query list
|
||||
# endpoints (see issue #32771). The new handler swallows it but
|
||||
# logs a warning so the underlying breakage is still observable —
|
||||
# pinned here so a future refactor that collapses the case into
|
||||
# the silent handler fails this test.
|
||||
(DatasetNotFoundError("Dataset 1 not found!"), True),
|
||||
(SupersetTemplateException("Template rendering failed"), True),
|
||||
TemplateError,
|
||||
],
|
||||
)
|
||||
def test_sql_tables_mixin_sql_tables_exception(
|
||||
klass: type[Model],
|
||||
exception: Exception,
|
||||
should_warn: bool,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch(
|
||||
"superset.models.sql_lab.process_jinja_sql",
|
||||
side_effect=exception,
|
||||
)
|
||||
warning_spy = mocker.spy(sql_lab_module.logger, "warning")
|
||||
|
||||
assert klass(sql="SELECT 1", database=MagicMock()).sql_tables == []
|
||||
|
||||
if should_warn:
|
||||
assert warning_spy.call_count == 1, (
|
||||
f"{type(exception).__name__} should hit the warning-logging "
|
||||
"handler; if this fails, the case was likely collapsed into "
|
||||
"the silent first-handler clause."
|
||||
)
|
||||
else:
|
||||
warning_spy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"klass",
|
||||
|
||||
Reference in New Issue
Block a user