Compare commits

..

5 Commits

Author SHA1 Message Date
Amin Ghadersohi
e32a683f6f fix(mcp): handle bare string point_radius_fixed as metric in Deck.gl fallback
Legacy deck_scatter charts store point_radius_fixed as a plain metric key
string (e.g. "count") rather than the dict form {"type":"metric","value":...}.
The frontend isMetricValue() treats any non-numeric string as a valid metric
ref, so the fallback query builder now does the same via _is_metric_ref.
2026-05-27 21:06:13 +00:00
Amin Ghadersohi
362b9faaab fix(mcp): preserve string metric keys and add geojson null filter in Deck.gl fallback
- Add _is_metric_ref() to distinguish saved metric keys (non-numeric strings
  like "count") from fixed display settings (numeric strings like "100");
  both size and metric fields are now validated with this helper so string
  metric references are no longer silently dropped
- Extend _deck_gl_null_filters() to add IS NOT NULL for the geojson column,
  mirroring the behavior of DeckGeoJson when filter_nulls is enabled
- Add unit tests for _is_metric_ref and the new integration paths
2026-05-27 20:09:08 +00:00
Amin Ghadersohi
fdd97d42ad fix(mcp): guard Deck.gl metric extraction against scalar size and deck_geojson
_resolve_deck_gl_metrics now:
- Returns [] immediately for deck_geojson, matching DeckGeoJson.query_obj()
  which explicitly forces metrics=[] regardless of form_data
- Only includes size/metric values that are dicts (metric references);
  scalar values like "100" in deck_geojson/deck_path fixtures are fixed
  display settings and must not be passed as query metrics

Adds viz_type param and passes it from build_query_dicts_from_form_data.
Adds regression tests using scalar size fixtures.
2026-05-27 19:07:00 +00:00
Amin Ghadersohi
1d276e67b2 fix(mcp): include point_radius_fixed metrics, null filters, and tooltip columns in Deck.gl fallback
- Extract _resolve_deck_gl_metrics to handle point_radius_fixed when
  type=="metric" (deck_scatter radius, deck_polygon elevation)
- Add _deck_gl_null_filters to mirror BaseDeckGLViz.add_null_filters()
  behavior; applied by default when filter_nulls is not False
- Extend resolve_deck_gl_columns to include tooltip_contents column
  items and cross_filter_column, matching the normal Deck.gl query
- Add _deck_gl_tooltip_cols helper for tooltip column extraction
- Add unit tests covering all three behaviors
2026-05-27 17:08:06 +00:00
Amin Ghadersohi
ed15be88d9 fix(mcp): fall back to form_data query for Deck.gl charts in get_chart_data
Deck.gl chart types (deck_scatter, deck_arc, deck_hex, etc.) use spatial
column configs (lat/lon pairs, geohash, delimited coordinates) rather than
the standard metrics/groupby structure. The get_chart_data MCP tool was
returning an early MissingQueryContext error for these charts when no
saved query_context was present, instead of falling back to form_data.

- Add _deck_gl_spatial_cols and resolve_deck_gl_columns helpers to
  chart_helpers.py to extract SQL column names from spatial control configs
  (latlong, delimited, geohash) plus line_column, geojson, dimension, and
  js_columns fields.
- Route deck_ viz types through the new helpers inside
  build_query_dicts_from_form_data, producing a raw-column or
  grouped-metric query matching BaseDeckGLViz.query_obj() semantics.
- Remove the early-exit error block for deck_ charts in get_chart_data.py;
  the existing safety-net (empty columns + empty metrics) still guards
  charts whose spatial config is completely absent.
- Add unit tests covering latlong, delimited, geohash, arc start/end,
  line_column, geojson, dimension, js_columns, deduplication, and the
  build_query_dicts_from_form_data Deck.gl branch.
2026-05-22 00:47:44 +00:00
18 changed files with 1204 additions and 933 deletions

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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": "*",

View File

@@ -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>;
}

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
});

View File

@@ -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(

View File

@@ -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,

View File

@@ -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"
)

View File

@@ -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,

View File

@@ -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(

View File

@@ -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]}})

View File

@@ -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

View File

@@ -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"]

View File

@@ -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",