Compare commits

...
Author SHA1 Message Date
Enzo Martellucci b2acbd286a fix(chart): respect ALLOW_FULL_CSV_EXPORT and fix streaming CSV export 2026-05-21 15:35:49 +02:00
Enzo Martellucci e8317b15e2 refactor(chart): rename fullCsvMaxRows to fullExportMaxRows 2026-05-21 14:38:53 +02:00
Enzo Martellucci 584917b467 fix(chart): respect ALLOW_FULL_CSV_EXPORT and fix streaming CSV export 2026-05-21 14:25:32 +02:00
dependabot[bot] a183582291 chore(deps): bump markdown-to-jsx from 9.8.0 to 9.8.1 in /superset-frontend (#40316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:33:08 -07:00
dependabot[bot] 3acef94ef6 chore(deps): update zod requirement from ^4.4.1 to ^4.4.3 in /superset-frontend/plugins/plugin-chart-echarts (#40313)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:32:46 -07:00
dependabot[bot] 9638eecdb1 chore(deps-dev): bump oxlint from 1.65.0 to 1.66.0 in /superset-frontend (#40318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:29:45 -07:00
Evan RusackasandClaude 7e74fc4192 fix(charts): handle PostgreSQL INTERVAL type in bar and pie charts (#34513)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 22:26:59 -07:00
Evan RusackasandClaude Opus 4.7 cdca6f7fdc fix(sqllab): keep saved-query list working when Jinja dataset(id) references a deleted dataset (#39703)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:19:19 -07:00
b1ca8cac6b fix(tests): fix flaky FileHandler test by awaiting LaunchQueue consumer in afterEach (#39508)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: sadpandajoe <jcli38@gmail.com>
2026-05-20 19:31:01 -07:00
Evan RusackasandClaude Code 2cd5efa627 ci(deps): bump lower bound on pip dependabot PRs (#40308)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-21 08:53:57 +07:00
20 changed files with 731 additions and 470 deletions
+5
View File
@@ -62,6 +62,11 @@ 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:
+371 -420
View File
File diff suppressed because it is too large Load Diff
+2 -2
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.0",
"markdown-to-jsx": "^9.8.1",
"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.65.0",
"oxlint": "^1.66.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.1"
"zod": "^4.4.3"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -97,7 +97,7 @@ export function createWrapper(options?: Options) {
}
if (useDnd) {
// @ts-expect-error react-dnd types not updated for React 18
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
}
@@ -90,7 +90,13 @@ const defaultState = {
superset_can_explore: false,
superset_can_share: false,
superset_can_csv: false,
common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0, SQL_MAX_ROW: 666 } },
common: {
conf: {
SUPERSET_WEBSERVER_TIMEOUT: 0,
SQL_MAX_ROW: 666,
TABLE_VIZ_MAX_ROW_SERVER: 999,
},
},
},
dashboardLayout: {
present: {},
@@ -201,7 +207,7 @@ test('should call exportChart when exportCSV is clicked', async () => {
stubbedExportCSV.mockRestore();
});
test('should call exportChart with row_limit props.maxRows when exportFullCSV is clicked', async () => {
test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when exportFullCSV is clicked', async () => {
(global as any).featureFlags = {
[FeatureFlag.AllowFullCsvExport]: true,
};
@@ -222,7 +228,8 @@ test('should call exportChart with row_limit props.maxRows when exportFullCSV is
expect(stubbedExportCSV).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({
row_limit: 666,
row_limit: 999,
full_export: true,
dashboardId: 111,
}),
resultType: 'full',
@@ -256,7 +263,7 @@ test('should call exportChart when exportXLSX is clicked', async () => {
stubbedExportXLSX.mockRestore();
});
test('should call exportChart with row_limit props.maxRows when exportFullXLSX is clicked', async () => {
test('should call exportChart with row_limit TABLE_VIZ_MAX_ROW_SERVER when exportFullXLSX is clicked', async () => {
(global as any).featureFlags = {
[FeatureFlag.AllowFullCsvExport]: true,
};
@@ -277,7 +284,8 @@ test('should call exportChart with row_limit props.maxRows when exportFullXLSX i
expect(stubbedExportXLSX).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({
row_limit: 666,
row_limit: 999,
full_export: true,
dashboardId: 111,
}),
resultType: 'full',
@@ -224,8 +224,10 @@ const Chart = (props: ChartProps) => {
const emitCrossFilters = useSelector(
(state: RootState) => !!state.dashboardInfo.crossFiltersEnabled,
);
const maxRows: number = useSelector(
(state: RootState) => state.dashboardInfo.common.conf.SQL_MAX_ROW as number,
const fullExportMaxRows: number = useSelector(
(state: RootState) =>
(state.dashboardInfo.common.conf.TABLE_VIZ_MAX_ROW_SERVER as number) ||
(state.dashboardInfo.common.conf.SQL_MAX_ROW as number),
);
const streamingThreshold: number = useSelector(
(state: RootState) =>
@@ -480,7 +482,7 @@ const Chart = (props: ChartProps) => {
(formData as JsonObject).dashboardId = dashboardInfo.id;
const exportTable = useCallback(
(format: string, isFullCSV: boolean, isPivot = false) => {
(format: string, isFullExport: boolean, isPivot = false) => {
const logAction =
format === 'csv'
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
@@ -490,8 +492,11 @@ const Chart = (props: ChartProps) => {
is_cached: isCached,
});
const exportFormData = isFullCSV
? { ...formData, row_limit: maxRows }
// For a "full" export, raise the requested row_limit and flag the
// request with full_export so the backend lifts the row-limit cap to
// TABLE_VIZ_MAX_ROW_SERVER (gated by the ALLOW_FULL_CSV_EXPORT flag).
const exportFormData = isFullExport
? { ...formData, row_limit: fullExportMaxRows, full_export: true }
: formData;
const resultType = isPivot ? 'post_processed' : 'full';
@@ -579,7 +584,7 @@ const Chart = (props: ChartProps) => {
sliceVizType,
isCached,
formData,
maxRows,
fullExportMaxRows,
dataMaskOwnState,
chartState,
props.id,
@@ -117,6 +117,7 @@ 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
@@ -131,7 +132,11 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
if (fileHandle) {
const id = setTimeout(() => {
pendingTimerIds.delete(id);
consumer({ files: [fileHandle] });
consumerPromises.push(
Promise.resolve(consumer({ files: [fileHandle] })).then(
() => undefined,
),
);
}, 0);
pendingTimerIds.add(id);
}
@@ -165,9 +170,19 @@ beforeEach(() => {
.launchQueue;
});
afterEach(() => {
afterEach(async () => {
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;
});
@@ -66,7 +66,22 @@ class StreamingCSVExportCommand(BaseStreamingCSVExportCommand):
# Note: datasource should already be attached to a session from query_context
datasource = self._query_context.datasource
query_obj = self._query_context.queries[0]
sql_query = datasource.get_query_str(query_obj.to_dict())
query_dict = query_obj.to_dict()
# Use get_query_str_extended (single, clean statement) instead of
# get_query_str, which returns a multi-statement string (prequeries +
# main SQL joined by ";" with a trailing ";"). The base command runs the
# SQL through SQLAlchemy text(), which only accepts a single statement,
# so the multi-statement form fails on engines that emit prequeries
# (e.g. PostgreSQL/Snowflake "SET search_path"). Prequeries still run
# via the connect-event listener registered in Database.get_sqla_engine.
# get_query_str_extended lives on ExploreMixin, not the Explorable
# Protocol, so guard with getattr for datasources that lack it.
get_extended = getattr(datasource, "get_query_str_extended", None)
if callable(get_extended):
sql_query = get_extended(query_dict).sql
else:
sql_query = datasource.get_query_str(query_dict)
database = getattr(datasource, "database", None)
catalog = getattr(datasource, "catalog", None)
schema = getattr(datasource, "schema", None)
+6
View File
@@ -74,6 +74,11 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
bool(form_data.get("server_pagination")) if form_data else False
)
# A "full" CSV/Excel export raises the row-limit ceiling to
# TABLE_VIZ_MAX_ROW_SERVER (when ALLOW_FULL_CSV_EXPORT is enabled).
# The marker is set by the frontend's "Export to full ..." actions.
full_export = bool(form_data.get("full_export")) if form_data else False
queries_ = [
self._process_query_object(
datasource_model_instance,
@@ -82,6 +87,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
result_type,
datasource=datasource,
server_pagination=server_pagination,
full_export=full_export,
**query_obj,
),
)
+9 -2
View File
@@ -58,6 +58,7 @@ class QueryObjectFactory: # pylint: disable=too-few-public-methods
time_range: str | None = None,
time_shift: str | None = None,
server_pagination: bool | None = None,
full_export: bool | None = None,
**kwargs: Any,
) -> QueryObject:
datasource_model_instance = None
@@ -66,9 +67,12 @@ class QueryObjectFactory: # pylint: disable=too-few-public-methods
processed_extras = self._process_extras(extras)
result_type = kwargs.setdefault("result_type", parent_result_type)
# Process row limit taking server pagination into account
# Process row limit taking server pagination and full export into account
row_limit = self._process_row_limit(
row_limit, result_type, server_pagination=server_pagination
row_limit,
result_type,
server_pagination=server_pagination,
full_export=full_export,
)
processed_time_range = self._process_time_range(
@@ -106,12 +110,14 @@ class QueryObjectFactory: # pylint: disable=too-few-public-methods
row_limit: int | None,
result_type: ChartDataResultType,
server_pagination: bool | None = None,
full_export: bool | None = None,
) -> int:
"""Process row limit taking into account server pagination.
:param row_limit: The requested row limit
:param result_type: The type of result being processed
:param server_pagination: Whether server-side pagination is enabled
:param full_export: Whether this is a "full" CSV/Excel export request
:return: The processed row limit
"""
default_row_limit = (
@@ -122,6 +128,7 @@ class QueryObjectFactory: # pylint: disable=too-few-public-methods
return apply_max_row_limit(
row_limit or default_row_limit,
server_pagination=server_pagination,
full_export=full_export,
)
@staticmethod
+3 -1
View File
@@ -1317,7 +1317,9 @@ MAPBOX_API_KEY = os.environ.get("MAPBOX_API_KEY", "")
# Maximum number of rows returned for any analytical database query
SQL_MAX_ROW = 100000
# Maximum number of rows for any query with Server Pagination in Table Viz type
# Maximum number of rows for any query with Server Pagination in Table Viz type.
# This also serves as the row-count ceiling for "full" CSV/Excel exports when the
# ALLOW_FULL_CSV_EXPORT feature flag is enabled (see apply_max_row_limit).
TABLE_VIZ_MAX_ROW_SERVER = 500000
+40 -2
View File
@@ -21,10 +21,11 @@ import logging
import re
from datetime import datetime
from re import Pattern
from typing import Any, Optional, TYPE_CHECKING
from typing import Any, Callable, Optional, TYPE_CHECKING
from flask_babel import gettext as __
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
from sqlalchemy import types
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
from sqlalchemy.dialects.postgresql.base import PGInspector
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
@@ -135,6 +136,34 @@ 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"""
@@ -526,8 +555,17 @@ 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,
+13 -1
View File
@@ -53,7 +53,11 @@ from superset_core.queries.models import (
)
from superset import security_manager
from superset.exceptions import SupersetParseError, SupersetSecurityException
from superset.exceptions import (
SupersetException,
SupersetParseError,
SupersetSecurityException,
)
from superset.explorables.base import TimeGrainDict
from superset.jinja_context import BaseTemplateProcessor, get_template_processor
from superset.models.helpers import (
@@ -99,6 +103,14 @@ 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(
+21 -5
View File
@@ -2051,13 +2051,17 @@ def parse_boolean_string(bool_str: str | None) -> bool:
def apply_max_row_limit(
limit: int,
server_pagination: bool | None = None,
full_export: bool | None = None,
) -> int:
"""
Override row limit based on server pagination setting
Override row limit based on server pagination / full-export settings
:param limit: requested row limit
:param server_pagination: whether server-side pagination
is enabled, defaults to None
:param full_export: whether this is a "full" CSV/Excel export request,
which raises the ceiling to TABLE_VIZ_MAX_ROW_SERVER when the
ALLOW_FULL_CSV_EXPORT feature flag is enabled, defaults to None
:return: Capped row limit
>>> apply_max_row_limit(600000, server_pagination=True) # Server pagination
@@ -2069,13 +2073,25 @@ def apply_max_row_limit(
>>> apply_max_row_limit(0) # Zero returns default max limit
50000
"""
# Imported locally to avoid a circular import: superset.extensions pulls in
# superset.security.manager / superset.utils.cache_manager, both of which
# import superset.utils.core.
# pylint: disable=import-outside-toplevel
from superset.extensions import feature_flag_manager
max_limit = (
app.config["TABLE_VIZ_MAX_ROW_SERVER"]
if server_pagination
else app.config["SQL_MAX_ROW"]
# A "full" CSV/Excel export is allowed past the regular SQL_MAX_ROW cap, but
# only when the operator has opted in via the ALLOW_FULL_CSV_EXPORT flag.
# server_pagination is a separate, independent reason to raise the cap and is
# NOT gated by that flag.
allow_full_export = full_export and feature_flag_manager.is_feature_enabled(
"ALLOW_FULL_CSV_EXPORT"
)
# Both raised cases share the same ceiling, TABLE_VIZ_MAX_ROW_SERVER (a
# bounded, predictable maximum); see its definition in config.py.
if server_pagination or allow_full_export:
max_limit = app.config["TABLE_VIZ_MAX_ROW_SERVER"]
else:
max_limit = app.config["SQL_MAX_ROW"]
if limit != 0:
return min(max_limit, limit)
return max_limit
@@ -29,6 +29,7 @@ def _setup_chart_mocks(
sql: str = "SELECT * FROM test",
catalog: str | None = None,
schema: str | None = None,
prequeries: list[str] | None = None,
) -> tuple[MockerFixture, MockerFixture, MockerFixture]:
"""Set up common mocks for chart streaming export tests."""
mock_db = mocker.patch("superset.commands.streaming_export.base.db")
@@ -37,12 +38,22 @@ def _setup_chart_mocks(
query_context = mocker.MagicMock()
datasource = mocker.MagicMock()
datasource.get_query_str.return_value = sql
# The command prefers get_query_str_extended (clean single statement);
# get_query_str returns the legacy multi-statement form.
extended = mocker.MagicMock()
extended.sql = sql
extended.prequeries = prequeries or []
datasource.get_query_str_extended.return_value = extended
datasource.get_query_str.return_value = (
";\n\n".join((prequeries or []) + [sql]) + ";"
)
datasource.database = mocker.MagicMock()
datasource.catalog = catalog
datasource.schema = schema
query_context.datasource = datasource
query_context.queries = [mocker.MagicMock()]
query_obj = mocker.MagicMock()
query_obj.to_dict.return_value = {"row_limit": 100}
query_context.queries = [query_obj]
mock_session.merge.return_value = datasource.database
return mock_db, query_context, datasource
@@ -296,3 +307,39 @@ def test_catalog_and_schema_passed_to_engine(mocker: MockerFixture) -> None:
catalog="my_catalog",
schema="my_schema",
)
def test_uses_extended_sql_single_statement(mocker: MockerFixture) -> None:
"""SQL generation uses get_query_str_extended (no prequeries, no trailing ;).
get_query_str returns a multi-statement string that SQLAlchemy text()
rejects; the command must use the clean single-statement extended form.
"""
_, query_context, datasource = _setup_chart_mocks(
mocker,
sql="SELECT * FROM test",
prequeries=["SET search_path = my_schema"],
)
command = StreamingCSVExportCommand(query_context)
sql_query, _, _, _ = command._get_sql_and_database()
assert sql_query == "SELECT * FROM test"
assert ";\n\n" not in sql_query
assert not sql_query.endswith(";")
datasource.get_query_str.assert_not_called()
def test_falls_back_to_get_query_str_without_extended(
mocker: MockerFixture,
) -> None:
"""Datasources lacking get_query_str_extended fall back to get_query_str."""
_, query_context, datasource = _setup_chart_mocks(mocker)
# SemanticView and other Explorables may not implement the extended form.
del datasource.get_query_str_extended
command = StreamingCSVExportCommand(query_context)
sql_query, _, _, _ = command._get_sql_and_database()
datasource.get_query_str.assert_called_once()
assert "SELECT * FROM test" in sql_query
@@ -30,6 +30,7 @@ def create_app_config() -> dict[str, Any]:
"DEFAULT_RELATIVE_END_TIME": "today",
"SAMPLES_ROW_LIMIT": 1000,
"SQL_MAX_ROW": 100000,
"TABLE_VIZ_MAX_ROW_SERVER": 500000,
}
@@ -48,12 +49,12 @@ def connector_registry() -> Mock:
def apply_max_row_limit(
limit: int,
server_pagination: bool | None = None,
full_export: bool | None = None,
) -> int:
max_limit = (
create_app_config()["TABLE_VIZ_MAX_ROW_SERVER"]
if server_pagination
else create_app_config()["SQL_MAX_ROW"]
)
if server_pagination or full_export:
max_limit = create_app_config()["TABLE_VIZ_MAX_ROW_SERVER"]
else:
max_limit = create_app_config()["SQL_MAX_ROW"]
if limit != 0:
return min(max_limit, limit)
return max_limit
@@ -109,6 +110,36 @@ class TestQueryObjectFactory:
assert query_object.row_limit == 100
assert query_object.row_offset == 200
def test_query_context_full_export_raises_limit(
self,
query_object_factory: QueryObjectFactory,
raw_query_context: dict[str, Any],
):
"""full_export raises the row-limit ceiling to TABLE_VIZ_MAX_ROW_SERVER."""
raw_query_object = raw_query_context["queries"][0]
raw_query_object["row_limit"] = 300000
query_object = query_object_factory.create(
raw_query_context["result_type"],
full_export=True,
**raw_query_object,
)
# Without full_export this would be capped at SQL_MAX_ROW (100000).
assert query_object.row_limit == 300000
def test_query_context_limit_capped_without_full_export(
self,
query_object_factory: QueryObjectFactory,
raw_query_context: dict[str, Any],
):
"""A regular request stays capped at SQL_MAX_ROW."""
raw_query_object = raw_query_context["queries"][0]
raw_query_object["row_limit"] = 300000
query_object = query_object_factory.create(
raw_query_context["result_type"],
**raw_query_object,
)
assert query_object.row_limit == 100000
def test_query_context_null_post_processing_op(
self,
query_object_factory: QueryObjectFactory,
@@ -15,14 +15,14 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from datetime import datetime, timedelta
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, JSON
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.engine.url import make_url
@@ -87,6 +87,8 @@ 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(
@@ -366,3 +368,38 @@ 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
+45 -12
View File
@@ -21,8 +21,14 @@ 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
from superset.exceptions import (
SupersetParseError,
SupersetSecurityException,
SupersetTemplateException,
)
from superset.models import sql_lab as sql_lab_module
from superset.models.sql_lab import Query, SavedQuery
@@ -34,34 +40,61 @@ from superset.models.sql_lab import Query, SavedQuery
],
)
@pytest.mark.parametrize(
"exception",
("exception", "should_warn"),
[
SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
message="",
level=ErrorLevel.ERROR,
)
# 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,
),
SupersetParseError(
sql="INVALID SQL",
message="Invalid SQL syntax",
(
SupersetParseError(
sql="INVALID SQL",
message="Invalid SQL syntax",
),
False,
),
TemplateError,
(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),
],
)
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",
+33
View File
@@ -28,6 +28,7 @@ from pytest_mock import MockerFixture
from superset.exceptions import SupersetException
from superset.utils.core import (
apply_max_row_limit,
cast_to_boolean,
check_is_safe_zip,
DateColumn,
@@ -53,6 +54,7 @@ from superset.utils.core import (
sanitize_url,
)
from tests.conftest import with_config
from tests.unit_tests.conftest import with_feature_flags
ADHOC_FILTER: QueryObjectFilterClause = {
"col": "foo",
@@ -1730,3 +1732,34 @@ def test_markdown_with_markup_wrap() -> None:
assert isinstance(result, Markup)
assert "<strong>bold</strong>" in str(result)
@with_config({"SQL_MAX_ROW": 100000, "TABLE_VIZ_MAX_ROW_SERVER": 500000})
def test_apply_max_row_limit_default_cap() -> None:
"""A regular request is capped at SQL_MAX_ROW."""
assert apply_max_row_limit(300000) == 100000
assert apply_max_row_limit(5000) == 5000
# 0 means "no explicit limit" -> default max
assert apply_max_row_limit(0) == 100000
@with_config({"SQL_MAX_ROW": 100000, "TABLE_VIZ_MAX_ROW_SERVER": 500000})
def test_apply_max_row_limit_server_pagination() -> None:
"""server_pagination raises the cap to TABLE_VIZ_MAX_ROW_SERVER."""
assert apply_max_row_limit(300000, server_pagination=True) == 300000
assert apply_max_row_limit(900000, server_pagination=True) == 500000
@with_config({"SQL_MAX_ROW": 100000, "TABLE_VIZ_MAX_ROW_SERVER": 500000})
@with_feature_flags(ALLOW_FULL_CSV_EXPORT=True)
def test_apply_max_row_limit_full_export_with_flag() -> None:
"""full_export raises the cap to TABLE_VIZ_MAX_ROW_SERVER when the flag is on."""
assert apply_max_row_limit(300000, full_export=True) == 300000
assert apply_max_row_limit(900000, full_export=True) == 500000
@with_config({"SQL_MAX_ROW": 100000, "TABLE_VIZ_MAX_ROW_SERVER": 500000})
@with_feature_flags(ALLOW_FULL_CSV_EXPORT=False)
def test_apply_max_row_limit_full_export_without_flag() -> None:
"""full_export has no effect when ALLOW_FULL_CSV_EXPORT is disabled."""
assert apply_max_row_limit(300000, full_export=True) == 100000