This is an automated email from the ASF dual-hosted git repository.
kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new f5a5a804e23 perf(dashboard): skip thumbnail_url computing on single
dashboard endpoint (#38015)
f5a5a804e23 is described below
commit f5a5a804e239fa18048be44cbc1025c8b78574cd
Author: Kamil Gabryjelski <[email protected]>
AuthorDate: Thu Feb 19 18:15:20 2026 +0100
perf(dashboard): skip thumbnail_url computing on single dashboard endpoint
(#38015)
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../src/connection/callApi/callApi.ts | 10 ++-
.../test/connection/callApi/callApi.test.ts | 15 ++++
.../src/hooks/apiResources/dashboards.test.ts | 23 ++++-
.../src/hooks/apiResources/dashboards.ts | 35 +++++++-
superset/dashboards/api.py | 28 +++++-
tests/integration_tests/dashboards/api_tests.py | 81 ++++++++++++++++++
tests/unit_tests/dashboards/api_test.py | 99 ++++++++++++++++++++++
7 files changed, 282 insertions(+), 9 deletions(-)
diff --git
a/superset-frontend/packages/superset-ui-core/src/connection/callApi/callApi.ts
b/superset-frontend/packages/superset-ui-core/src/connection/callApi/callApi.ts
index fddac0399c9..efe0687127a 100644
---
a/superset-frontend/packages/superset-ui-core/src/connection/callApi/callApi.ts
+++
b/superset-frontend/packages/superset-ui-core/src/connection/callApi/callApi.ts
@@ -41,12 +41,14 @@ function tryParsePayload(payload: Payload) {
*/
function getFullUrl(partialUrl: string, params: CallApi['searchParams']) {
if (params) {
- const url = new URL(partialUrl, window.location.href);
const search =
params instanceof URLSearchParams ? params : new URLSearchParams(params);
- // will completely override any existing search params
- url.search = search.toString();
- return url.href;
+ const searchString = search.toString();
+ if (searchString) {
+ const url = new URL(partialUrl, window.location.href);
+ url.search = searchString;
+ return url.href;
+ }
}
return partialUrl;
}
diff --git
a/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApi.test.ts
b/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApi.test.ts
index ad64f7fca14..8d45af31a53 100644
---
a/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApi.test.ts
+++
b/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApi.test.ts
@@ -591,6 +591,21 @@ describe('callApi()', () => {
);
});
+ test('should preserve existing query params when searchParams is empty',
async () => {
+ window.location.href = 'http://localhost';
+ fetchMock.get(`glob:*/get-search*`, { yes: 'ok' });
+ const response = await callApi({
+ url: '/get-search?q=existing',
+ searchParams: {},
+ method: 'GET',
+ });
+ const result = await response.json();
+ expect(result).toEqual({ yes: 'ok' });
+ expect(fetchMock.callHistory.lastCall()?.url).toEqual(
+ `http://localhost/get-search?q=existing`,
+ );
+ });
+
test('should accept URLSearchParams', async () => {
expect.assertions(2);
window.location.href = 'http://localhost';
diff --git a/superset-frontend/src/hooks/apiResources/dashboards.test.ts
b/superset-frontend/src/hooks/apiResources/dashboards.test.ts
index 7676c5c8e87..788c4ae7486 100644
--- a/superset-frontend/src/hooks/apiResources/dashboards.test.ts
+++ b/superset-frontend/src/hooks/apiResources/dashboards.test.ts
@@ -18,7 +18,28 @@
*/
import { renderHook } from '@testing-library/react-hooks';
import fetchMock from 'fetch-mock';
-import { useDashboardDatasets } from './dashboards';
+import { useDashboard, useDashboardDatasets } from './dashboards';
+
+test('useDashboard excludes thumbnail_url from request', async () => {
+ fetchMock.get('glob:*/api/v1/dashboard/5?q=*', {
+ result: {
+ id: 5,
+ dashboard_title: 'Test',
+ json_metadata: '{}',
+ position_json: '{}',
+ owners: [],
+ },
+ });
+
+ const { waitForNextUpdate } = renderHook(() => useDashboard(5));
+ await waitForNextUpdate();
+
+ const calledUrl = fetchMock.callHistory.lastCall()?.url ?? '';
+ expect(calledUrl).toContain('?q=');
+ expect(calledUrl).not.toContain('thumbnail_url');
+
+ fetchMock.clearHistory().removeRoutes();
+});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
describe('useDashboardDatasets', () => {
diff --git a/superset-frontend/src/hooks/apiResources/dashboards.ts
b/superset-frontend/src/hooks/apiResources/dashboards.ts
index 65e4b1c0bdd..81ed364e6ea 100644
--- a/superset-frontend/src/hooks/apiResources/dashboards.ts
+++ b/superset-frontend/src/hooks/apiResources/dashboards.ts
@@ -17,14 +17,42 @@
* under the License.
*/
+import rison from 'rison';
import { Dashboard, Datasource, EmbeddedDashboard } from 'src/dashboard/types';
import { Chart } from 'src/types/Chart';
import { Currency } from '@superset-ui/core';
import { useApiV1Resource, useTransformedResource } from './apiResources';
-export const useDashboard = (idOrSlug: string | number) =>
- useTransformedResource(
- useApiV1Resource<Dashboard>(`/api/v1/dashboard/${idOrSlug}`),
+const DASHBOARD_GET_COLUMNS = [
+ 'id',
+ 'slug',
+ 'url',
+ 'dashboard_title',
+ 'published',
+ 'css',
+ 'theme',
+ 'json_metadata',
+ 'position_json',
+ 'certified_by',
+ 'certification_details',
+ 'changed_by_name',
+ 'changed_by',
+ 'changed_on',
+ 'created_by',
+ 'charts',
+ 'owners',
+ 'roles',
+ 'tags',
+ 'changed_on_delta_humanized',
+ 'created_on_delta_humanized',
+ 'is_managed_externally',
+ 'uuid',
+];
+
+export const useDashboard = (idOrSlug: string | number) => {
+ const q = rison.encode({ columns: DASHBOARD_GET_COLUMNS });
+ return useTransformedResource(
+ useApiV1Resource<Dashboard>(`/api/v1/dashboard/${idOrSlug}?q=${q}`),
dashboard => ({
...dashboard,
// TODO: load these at the API level
@@ -35,6 +63,7 @@ export const useDashboard = (idOrSlug: string | number) =>
owners: dashboard.owners || [],
}),
);
+};
// gets the chart definitions for a dashboard
export const useDashboardCharts = (idOrSlug: string | number) =>
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index cdbed39552b..bcb92231e56 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -22,6 +22,7 @@ from io import BytesIO
from typing import Any, Callable, cast
from zipfile import is_zipfile, ZipFile
+import prison
from flask import current_app, g, redirect, request, Response, send_file,
url_for
from flask_appbuilder import permission_name
from flask_appbuilder.api import expose, merge_response_func, protect, rison,
safe
@@ -467,6 +468,12 @@ class DashboardRestApi(CustomTagsOptimizationMixin,
BaseSupersetModelRestApi):
type: string
name: id_or_slug
description: Either the id of the dashboard, or its slug
+ - in: query
+ name: q
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/get_item_schema'
responses:
200:
description: Dashboard
@@ -486,7 +493,26 @@ class DashboardRestApi(CustomTagsOptimizationMixin,
BaseSupersetModelRestApi):
404:
$ref: '#/components/responses/404'
"""
- result = self.dashboard_get_response_schema.dump(dash)
+ columns: list[str] | None = None
+ if q := request.args.get("q"):
+ try:
+ args = prison.loads(q)
+ except Exception:
+ return self.response_400(message="Invalid rison query
parameter")
+ if isinstance(args, dict):
+ columns = args.get("columns")
+
+ if columns:
+ schema_fields = self.dashboard_get_response_schema.fields
+ key_to_name = {
+ field.data_key or name: name for name, field in
schema_fields.items()
+ }
+ only = [key_to_name[c] for c in columns if c in key_to_name]
+ schema = DashboardGetResponseSchema(only=only)
+ else:
+ schema = self.dashboard_get_response_schema
+
+ result = schema.dump(dash)
add_extra_log_payload(
dashboard_id=dash.id, action=f"{self.__class__.__name__}.get"
)
diff --git a/tests/integration_tests/dashboards/api_tests.py
b/tests/integration_tests/dashboards/api_tests.py
index ca0e8fa8968..f71af99ad40 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -569,6 +569,87 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
db.session.delete(dashboard)
db.session.commit()
+ def test_get_dashboard_with_columns(self):
+ """
+ Dashboard API: Test get dashboard with column selection via q param
+ """
+ admin = self.get_user("admin")
+ dashboard = self.insert_dashboard(
+ "title", "slug1", [admin.id], created_by=admin
+ )
+ self.login(ADMIN_USERNAME)
+ params = prison.dumps({"columns": ["id", "dashboard_title"]})
+ uri = f"api/v1/dashboard/{dashboard.id}?q={params}"
+ rv = self.get_assert_metric(uri, "get")
+ assert rv.status_code == 200
+ data = json.loads(rv.data.decode("utf-8"))
+ result = data["result"]
+ assert result["id"] == dashboard.id
+ assert result["dashboard_title"] == "title"
+ assert "thumbnail_url" not in result
+ assert "slug" not in result
+ assert "owners" not in result
+ # rollback changes
+ db.session.delete(dashboard)
+ db.session.commit()
+
+ def test_get_dashboard_with_invalid_rison_q(self):
+ """
+ Dashboard API: Test get dashboard with malformed rison returns 400
+ """
+ admin = self.get_user("admin")
+ dashboard = self.insert_dashboard(
+ "title", "slug1", [admin.id], created_by=admin
+ )
+ self.login(ADMIN_USERNAME)
+ uri = f"api/v1/dashboard/{dashboard.id}?q=(("
+ rv = self.get_assert_metric(uri, "get")
+ assert rv.status_code == 400
+ # rollback changes
+ db.session.delete(dashboard)
+ db.session.commit()
+
+ def test_get_dashboard_with_non_dict_q(self):
+ """
+ Dashboard API: Test get dashboard with non-dict rison returns full
response
+ """
+ admin = self.get_user("admin")
+ dashboard = self.insert_dashboard(
+ "title", "slug1", [admin.id], created_by=admin
+ )
+ self.login(ADMIN_USERNAME)
+ uri = f"api/v1/dashboard/{dashboard.id}?q=a_string"
+ rv = self.get_assert_metric(uri, "get")
+ assert rv.status_code == 200
+ data = json.loads(rv.data.decode("utf-8"))
+ # non-dict q is ignored, full response returned
+ assert "thumbnail_url" in data["result"]
+ # rollback changes
+ db.session.delete(dashboard)
+ db.session.commit()
+
+ def test_get_dashboard_with_columns_data_key_mapping(self):
+ """
+ Dashboard API: Test that data_key columns like
changed_on_delta_humanized work
+ """
+ admin = self.get_user("admin")
+ dashboard = self.insert_dashboard(
+ "title", "slug1", [admin.id], created_by=admin
+ )
+ self.login(ADMIN_USERNAME)
+ params = prison.dumps({"columns": ["id",
"changed_on_delta_humanized"]})
+ uri = f"api/v1/dashboard/{dashboard.id}?q={params}"
+ rv = self.get_assert_metric(uri, "get")
+ assert rv.status_code == 200
+ data = json.loads(rv.data.decode("utf-8"))
+ result = data["result"]
+ assert "id" in result
+ assert "changed_on_delta_humanized" in result
+ assert "dashboard_title" not in result
+ # rollback changes
+ db.session.delete(dashboard)
+ db.session.commit()
+
@patch("superset.dashboards.schemas.security_manager.has_guest_access")
@patch("superset.dashboards.schemas.security_manager.is_guest_user")
def test_get_dashboard_as_guest(self, is_guest_user, has_guest_access):
diff --git a/tests/unit_tests/dashboards/api_test.py
b/tests/unit_tests/dashboards/api_test.py
new file mode 100644
index 00000000000..5f4c5e680d9
--- /dev/null
+++ b/tests/unit_tests/dashboards/api_test.py
@@ -0,0 +1,99 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from superset.dashboards.schemas import DashboardGetResponseSchema
+
+
[email protected]
+def mock_dashboard() -> MagicMock:
+ dash = MagicMock()
+ dash.id = 1
+ dash.slug = "test-slug"
+ dash.url = "/superset/dashboard/test-slug/"
+ dash.dashboard_title = "Test Dashboard"
+ dash.thumbnail_url = "http://example.com/thumb.png"
+ dash.published = True
+ dash.css = ""
+ dash.theme = None
+ dash.json_metadata = "{}"
+ dash.position_json = "{}"
+ dash.certified_by = None
+ dash.certification_details = None
+ dash.changed_by_name = "admin"
+ dash.changed_by = MagicMock(id=1, first_name="admin", last_name="user")
+ dash.changed_on = None
+ dash.changed_on_humanized = "2 days ago"
+ dash.created_by = MagicMock(id=1, first_name="admin", last_name="user")
+ dash.created_on_humanized = "5 days ago"
+ dash.charts = []
+ dash.owners = []
+ dash.roles = []
+ dash.tags = []
+ dash.custom_tags = []
+ dash.is_managed_externally = False
+ dash.uuid = None
+ return dash
+
+
+def test_schema_column_selection_excludes_thumbnail(
+ mock_dashboard: MagicMock,
+) -> None:
+ schema = DashboardGetResponseSchema(only=["id", "dashboard_title"])
+ result = schema.dump(mock_dashboard)
+ assert "id" in result
+ assert "dashboard_title" in result
+ assert "thumbnail_url" not in result
+ assert "slug" not in result
+
+
+def test_schema_column_selection_with_data_key(
+ mock_dashboard: MagicMock,
+) -> None:
+ """Fields with data_key should work when using the internal field name."""
+ schema = DashboardGetResponseSchema(only=["id", "changed_on_humanized"])
+ result = schema.dump(mock_dashboard)
+ assert "id" in result
+ assert "changed_on_delta_humanized" in result
+ assert "dashboard_title" not in result
+
+
+def test_schema_full_response_includes_thumbnail(
+ mock_dashboard: MagicMock,
+) -> None:
+ schema = DashboardGetResponseSchema()
+ result = schema.dump(mock_dashboard)
+ assert "thumbnail_url" in result
+ assert "id" in result
+ assert "dashboard_title" in result
+
+
+def test_data_key_mapping_logic() -> None:
+ """The key_to_name mapping used in the API correctly maps data_key to
field name."""
+ schema = DashboardGetResponseSchema()
+ key_to_name = {
+ field.data_key or name: name for name, field in schema.fields.items()
+ }
+ # changed_on_delta_humanized is the data_key for changed_on_humanized
+ assert key_to_name["changed_on_delta_humanized"] == "changed_on_humanized"
+ assert key_to_name["created_on_delta_humanized"] == "created_on_humanized"
+ # fields without data_key map to themselves
+ assert key_to_name["id"] == "id"
+ assert key_to_name["thumbnail_url"] == "thumbnail_url"