From c43effa4a321f64ce2ddde92ac80e3263740a4ed Mon Sep 17 00:00:00 2001 From: jesperct Date: Thu, 23 Jul 2026 21:14:30 -0300 Subject: [PATCH 1/7] fix(explore): show the beginning date on time-series x-axis line charts (#42046) --- .../src/MixedTimeseries/transformProps.ts | 17 +++++ .../src/Timeseries/transformProps.ts | 25 ++++++- .../MixedTimeseries/transformProps.test.ts | 55 +++++++++++++++ .../test/Timeseries/transformProps.test.ts | 69 +++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index 5e3599234a5..ee01fb6f89c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -641,7 +641,22 @@ export default function transformProps( const deduplicatedFormatter = showMaxLabel ? (() => { let lastLabel: string | undefined; + let lastValue: number | undefined; const wrapper = (value: number | string) => { + // ECharts formats the labels in repeated ascending passes. Reset the + // dedup state when the sequence restarts so a forced boundary label + // (e.g. the min date) isn't blanked by the previous pass's last label + // when both format identically (e.g. a May-to-May range). + if ( + typeof value === 'number' && + lastValue !== undefined && + value <= lastValue + ) { + lastLabel = undefined; + } + if (typeof value === 'number') { + lastValue = value; + } const label = typeof xAxisFormatter === 'function' ? (xAxisFormatter as Function)(value) @@ -743,6 +758,8 @@ export default function transformProps( ...(showMaxLabel && { showMaxLabel: true, alignMaxLabel: 'right', + showMinLabel: true, + alignMinLabel: 'left', }), }, minorTick: { show: minorTicks }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index a2ad93e9578..1493aeee897 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -883,7 +883,22 @@ export default function transformProps( const deduplicatedFormatter = showMaxLabel ? (() => { let lastLabel: string | undefined; + let lastValue: number | undefined; const wrapper = (value: number | string) => { + // ECharts formats the labels in repeated ascending passes. Reset the + // dedup state when the sequence restarts so a forced boundary label + // (e.g. the min date) isn't blanked by the previous pass's last label + // when both format identically (e.g. a May-to-May range). + if ( + typeof value === 'number' && + lastValue !== undefined && + value <= lastValue + ) { + lastLabel = undefined; + } + if (typeof value === 'number') { + lastValue = value; + } const label = typeof xAxisFormatter === 'function' ? (xAxisFormatter as Function)(value) @@ -921,12 +936,16 @@ export default function transformProps( formatter: deduplicatedFormatter, rotate: xAxisLabelRotation, interval: xAxisLabelInterval, - // Force last label on non-rotated time axes to prevent - // hideOverlap from hiding it. Skipped when rotated to - // avoid phantom labels at the axis boundary. + // Force the boundary labels on non-rotated time axes so the first + // and last dates stay visible: hideOverlap can hide the last label, + // and a min date that falls between "nice" ticks otherwise renders + // no beginning label. Skipped when rotated to avoid phantom labels + // at the axis boundary. ...(showMaxLabel && { showMaxLabel: true, alignMaxLabel: 'right', + showMinLabel: true, + alignMinLabel: 'left', }), }, minorTick: { show: minorTicks }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts index fc189cb8358..84c1765b83b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts @@ -1106,3 +1106,58 @@ test('tooltip time grain wiring: chart-level time grain drives the tooltip when expect(result).toContain('2021'); expect(result).not.toContain('2021-01-07'); }); + +const createTemporalMixedChartProps = (timeFormat: string) => { + const rows = [ + { __timestamp: Date.UTC(2003, 4, 1), metric: 10 }, + { __timestamp: Date.UTC(2004, 0, 1), metric: 20 }, + { __timestamp: Date.UTC(2005, 4, 1), metric: 30 }, + ]; + const q = createTestQueryData(rows, { + colnames: ['__timestamp', 'metric'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + label_map: { __timestamp: ['__timestamp'], metric: ['metric'] }, + }); + return createEchartsTimeseriesTestChartProps< + EchartsMixedTimeseriesFormData, + EchartsMixedTimeseriesProps + >({ + ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS, + defaultQueriesData: [q, q], + formData: { + ...formData, + x_axis: '__timestamp', + metrics: ['metric'], + metricsB: ['metric'], + groupby: [], + groupbyB: [], + timeGrainSqla: TimeGranularity.MONTH, + xAxisTimeFormat: timeFormat, + }, + queriesData: [q, q], + }); +}; + +test('x-axis forces showMinLabel for time grains so the beginning date stays visible (mixed)', () => { + const xAxis = transformProps(createTemporalMixedChartProps('smart_date')) + .echartOptions.xAxis as any; + expect(xAxis.axisLabel.showMinLabel).toBe(true); +}); + +test('x-axis dedup keeps the forced min label when the endpoints format identically (mixed)', () => { + // May→May range renders "May" at both boundaries; the dedup must reset per + // ECharts pass so the forced min label survives the second pass. + const { formatter } = ( + transformProps(createTemporalMixedChartProps('%b')).echartOptions + .xAxis as any + ).axisLabel; + const min = Date.UTC(2003, 4, 1); + const mid = Date.UTC(2004, 0, 1); + const max = Date.UTC(2005, 4, 1); + + formatter(min); + formatter(mid); + formatter(max); + + expect(formatter(min)).toBe('May'); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index a219c22f56c..9f51bcfcca8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -1517,6 +1517,45 @@ test('x-axis formatter deduplicates consecutive identical labels for coarse time expect(label4).toBe(''); }); +test('x-axis dedup keeps the forced min label when the endpoints format identically', () => { + // A May→May range renders "May" at both boundaries. ECharts formats labels in + // repeated ascending passes; the dedup must reset per pass so the forced min + // label isn't blanked by the previous pass's (identical) max label. + const data = [ + { __timestamp: Date.UTC(2003, 4, 1), sales: 100 }, + { __timestamp: Date.UTC(2004, 0, 1), sales: 200 }, + { __timestamp: Date.UTC(2005, 4, 1), sales: 300 }, + ]; + + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.MONTH, + xAxisTimeFormat: '%b', + }, + queriesData: [ + createTestQueryData(data, { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }), + ], + }); + + const { formatter } = (transformProps(chartProps).echartOptions.xAxis as any) + .axisLabel; + const min = Date.UTC(2003, 4, 1); + const mid = Date.UTC(2004, 0, 1); + const max = Date.UTC(2005, 4, 1); + + // First pass fills the dedup state, ending on the max label ("May"). + formatter(min); + formatter(mid); + formatter(max); + + // Second pass restarts at the min; it must not be blanked by the prior "May". + expect(formatter(min)).toBe('May'); +}); + test('x-axis does not force showMaxLabel when no time grain is set', () => { const data = [ { __timestamp: Date.UTC(2003, 0, 6), sales: 100 }, @@ -1539,6 +1578,36 @@ test('x-axis does not force showMaxLabel when no time grain is set', () => { const xAxisResult = transformProps(chartProps).echartOptions.xAxis as any; expect(xAxisResult.axisLabel.showMaxLabel).not.toBe(true); + expect(xAxisResult.axisLabel.showMinLabel).not.toBe(true); +}); + +test('x-axis forces showMinLabel for time grains so the beginning date stays visible', () => { + // When the first data point is not on a coarse boundary (e.g. a mid-year + // month), ECharts places its first label on the next "nice" tick and leaves + // the axis-min date unlabeled. showMinLabel forces the beginning date to + // render, symmetric to showMaxLabel on the trailing edge. + const monthData = [ + { __timestamp: Date.UTC(2003, 4, 1), sales: 100 }, + { __timestamp: Date.UTC(2003, 5, 1), sales: 200 }, + { __timestamp: Date.UTC(2003, 6, 1), sales: 300 }, + ]; + + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.MONTH, + xAxisTimeFormat: 'smart_date', + }, + queriesData: [ + createTestQueryData(monthData, { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }), + ], + }); + + const xAxisResult = transformProps(chartProps).echartOptions.xAxis as any; + expect(xAxisResult.axisLabel.showMinLabel).toBe(true); }); test('numeric x coltype routes through the number formatter (not the time formatter)', () => { From d41f0febaee54958a1a73d86996c01f90d750b92 Mon Sep 17 00:00:00 2001 From: Abdul Rehman <76230556+Abdulrehman-PIAIC80387@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:15:24 +0500 Subject: [PATCH 2/7] fix(api): add example to get_export_ids_schema so Swagger "Try it out" pre-fills a valid array (#42265) --- .../annotation_layers/annotations/schemas.py | 6 +++++- superset/annotation_layers/schemas.py | 6 +++++- superset/charts/schemas.py | 18 +++++++++++++++--- superset/css_templates/schemas.py | 6 +++++- superset/dashboards/schemas.py | 18 +++++++++++++++--- superset/databases/schemas.py | 6 +++++- superset/datasets/schemas.py | 12 ++++++++++-- superset/queries/saved_queries/schemas.py | 12 ++++++++++-- superset/reports/schemas.py | 6 +++++- superset/row_level_security/schemas.py | 6 +++++- superset/tasks/schemas.py | 6 +++++- superset/themes/schemas.py | 12 ++++++++++-- 12 files changed, 95 insertions(+), 19 deletions(-) diff --git a/superset/annotation_layers/annotations/schemas.py b/superset/annotation_layers/annotations/schemas.py index 6ca96a4a749..59c9fc96a79 100644 --- a/superset/annotation_layers/annotations/schemas.py +++ b/superset/annotation_layers/annotations/schemas.py @@ -38,7 +38,11 @@ openapi_spec_methods_override = { "info": {"get": {"summary": "Get metadata information about this API resource"}}, } -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} annotation_start_dttm = "The annotation start date time" annotation_end_dttm = "The annotation end date time" diff --git a/superset/annotation_layers/schemas.py b/superset/annotation_layers/schemas.py index 1992d423fc4..cbcc2a26cbb 100644 --- a/superset/annotation_layers/schemas.py +++ b/superset/annotation_layers/schemas.py @@ -34,7 +34,11 @@ openapi_spec_methods_override = { "info": {"get": {"summary": "Get metadata information about this API resource"}}, } -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} annotation_layer_name = "The annotation layer name" annotation_layer_descr = "Give a description for this annotation layer" diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 97da753eed5..d50c8f4b4e4 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -104,7 +104,11 @@ def validate_prophet_periods(value: int) -> None: # # RISON/JSON schemas for query parameters # -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} width_height_schema = { "type": "array", @@ -122,9 +126,17 @@ screenshot_query_schema = { "thumb_size": width_height_schema, }, } -get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_export_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} -get_fav_star_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_fav_star_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} # # Column schema descriptions diff --git a/superset/css_templates/schemas.py b/superset/css_templates/schemas.py index 26bcc61a756..86f7f487e20 100644 --- a/superset/css_templates/schemas.py +++ b/superset/css_templates/schemas.py @@ -32,4 +32,8 @@ openapi_spec_methods_override = { "info": {"get": {"summary": "Get metadata information about this API resource"}}, } -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index b6e60f6224e..7dbfe948951 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -26,9 +26,21 @@ from superset.tags.models import TagType from superset.utils import json from superset.utils.schema import validate_external_url -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} -get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} -get_fav_star_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} +get_export_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} +get_fav_star_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} thumbnail_query_schema = { "type": "object", "properties": {"force": {"type": "boolean"}}, diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py index 97ca6e7733e..10a7cbac540 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -166,7 +166,11 @@ extra_description = markdown( "the default catalog when running queries and creating datasets.", True, ) -get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_export_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} sqlalchemy_uri_description = markdown( "Refer to the " "[SqlAlchemy docs]" diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py index bb54c138c5c..faa1e39592a 100644 --- a/superset/datasets/schemas.py +++ b/superset/datasets/schemas.py @@ -36,8 +36,16 @@ from superset.models.sql_types import parse_currency_string from superset.subjects.schemas import SubjectResponseSchema from superset.utils import json -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} -get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} +get_export_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} get_drill_info_schema = { "type": "object", "properties": { diff --git a/superset/queries/saved_queries/schemas.py b/superset/queries/saved_queries/schemas.py index ba33e24e1da..1c31d875cfc 100644 --- a/superset/queries/saved_queries/schemas.py +++ b/superset/queries/saved_queries/schemas.py @@ -42,8 +42,16 @@ openapi_spec_methods_override = { "info": {"get": {"summary": "Get metadata information about this API resource"}}, } -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} -get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} +get_export_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} class ImportV1SavedQuerySchema(Schema): diff --git a/superset/reports/schemas.py b/superset/reports/schemas.py index d9d703206bd..eadc5ee0d2c 100644 --- a/superset/reports/schemas.py +++ b/superset/reports/schemas.py @@ -49,7 +49,11 @@ openapi_spec_methods_override = { "info": {"get": {"summary": "Get metadata information about this API resource"}}, } -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} get_slack_channels_schema = { "type": "object", "properties": { diff --git a/superset/row_level_security/schemas.py b/superset/row_level_security/schemas.py index f4b355659e1..3acce2768c4 100644 --- a/superset/row_level_security/schemas.py +++ b/superset/row_level_security/schemas.py @@ -58,7 +58,11 @@ group_key_description = "Filters with the same group key will be ORed together w # pylint: disable=line-too-long clause_description = "This is the condition that will be added to the WHERE clause. For example, to only return rows for a particular client, you might define a regular filter with the clause `client_id = 9`. To display no rows unless a user belongs to a RLS filter role, a base filter can be created with the clause `1 = 0` (always false)." # noqa: E501 -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} openapi_spec_methods_override = { "get": {"get": {"summary": "Get an RLS"}}, diff --git a/superset/tasks/schemas.py b/superset/tasks/schemas.py index bf93c5d0c47..329e76d1c75 100644 --- a/superset/tasks/schemas.py +++ b/superset/tasks/schemas.py @@ -20,7 +20,11 @@ from marshmallow import fields, Schema from marshmallow.fields import Method # RISON/JSON schemas for query parameters -get_delete_ids_schema = {"type": "array", "items": {"type": "string"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "string"}, + "example": ["task_id_1", "task_id_2"], +} # Field descriptions uuid_description = "The unique identifier (UUID) of the task" diff --git a/superset/themes/schemas.py b/superset/themes/schemas.py index 099586d025c..28e40b58c67 100644 --- a/superset/themes/schemas.py +++ b/superset/themes/schemas.py @@ -184,5 +184,13 @@ openapi_spec_methods_override = { "info": {"get": {"summary": "Get metadata information about this API resource"}}, } -get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} -get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_delete_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} +get_export_ids_schema = { + "type": "array", + "items": {"type": "integer"}, + "example": [1, 2, 3], +} From 14c96761e557bd8ce619759f696c0855c4de6eb2 Mon Sep 17 00:00:00 2001 From: Hans Yu Date: Fri, 24 Jul 2026 02:16:38 +0200 Subject: [PATCH 3/7] chore: SQLAlchemy User cascade backref warnings are irrelevant (#42360) --- pytest.ini | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pytest.ini b/pytest.ini index c01b6d6bb49..c36cd30a588 100644 --- a/pytest.ini +++ b/pytest.ini @@ -36,11 +36,9 @@ filterwarnings = error:"TableColumn" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning error:"TaggedObject" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning error:The autoload parameter is deprecated:sqlalchemy.exc.RemovedIn20Warning - error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning error:The connection.execute\(\) method:sqlalchemy.exc.RemovedIn20Warning -# error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning + error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning error:The Engine.execute\(\) method is considered legacy:sqlalchemy.exc.RemovedIn20Warning error:The legacy calling style of select\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning error:The "whens" argument to case:sqlalchemy.exc.RemovedIn20Warning -# error:"User" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning From 5776aff50ac6e4069eb8858dca39ea6330d744e7 Mon Sep 17 00:00:00 2001 From: jenwitteng Date: Fri, 24 Jul 2026 08:01:05 +0700 Subject: [PATCH 4/7] fix(charts): handle async (202) chart-data responses in StatefulChart (#42157) Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Evan Rusackas --- .../chart/components/StatefulChart.test.tsx | 491 ++++++++++++++++-- .../src/chart/components/StatefulChart.tsx | 201 +++++-- .../src/chart/models/ChartProps.ts | 12 + .../src/components/Chart/ChartRenderer.tsx | 14 + .../src/components/Chart/chartAction.ts | 47 +- .../src/components/Chart/chartActions.test.ts | 107 ++++ .../src/middleware/asyncEvent.test.ts | 61 ++- .../src/middleware/asyncEvent.ts | 51 +- 8 files changed, 908 insertions(+), 76 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.test.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.test.tsx index 3d73326b45e..e1109a33710 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.test.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { render, waitFor, configure } from '@testing-library/react'; +import { render, waitFor, configure, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import StatefulChart from './StatefulChart'; import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton'; @@ -67,19 +67,19 @@ beforeEach(() => { jest.clearAllMocks(); // Setup default registry mocks - (getChartMetadataRegistry as any).mockReturnValue({ + jest.mocked(getChartMetadataRegistry).mockReturnValue({ get: jest.fn().mockReturnValue({ useLegacyApi: false, }), - }); + } as unknown as ReturnType); - (getChartBuildQueryRegistry as any).mockReturnValue({ + jest.mocked(getChartBuildQueryRegistry).mockReturnValue({ get: jest.fn().mockResolvedValue(null), - }); + } as unknown as ReturnType); - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(null), - }); + } as unknown as ReturnType); // Mock ChartClient constructor // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -113,9 +113,9 @@ test('should refetch data when non-renderTrigger control changes', async () => { ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const { rerender } = render( , @@ -165,9 +165,9 @@ test('should NOT refetch data when only renderTrigger controls change', async () ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const { rerender, getByTestId } = render( , @@ -201,9 +201,9 @@ test('should NOT refetch data when only renderTrigger controls change', async () test('should refetch when control panel config is not available', async () => { // No control panel config available - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(null), - }); + } as unknown as ReturnType); const { rerender } = render( , @@ -245,9 +245,9 @@ test('should refetch when viz_type changes', async () => { ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const { rerender } = render( , @@ -299,9 +299,9 @@ test('should handle mixed renderTrigger and non-renderTrigger changes', async () ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const { rerender } = render( , @@ -352,9 +352,9 @@ test('should handle controls with complex structure', async () => { ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const { rerender, getByTestId } = render( , @@ -404,11 +404,11 @@ test('should not refetch when formData has not changed', async () => { test('should handle errors gracefully when accessing registry', async () => { // Mock registry to throw an error - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockImplementation(() => { throw new Error('Registry error'); }), - }); + } as unknown as ReturnType); const { rerender } = render( , @@ -492,9 +492,9 @@ test('should NOT refetch data when string-based renderTrigger control (zoomable) ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const formDataWithZoom = { ...mockFormData, @@ -542,9 +542,9 @@ test('should NOT refetch data when other string-based renderTrigger controls cha ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const { rerender, getByTestId } = render( , @@ -585,9 +585,9 @@ test('should refetch when string control is NOT in RENDER_TRIGGER_SHARED_CONTROL ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const { rerender } = render( , @@ -631,9 +631,9 @@ test('should handle mixed string and object controls correctly', async () => { ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const formDataWithControls = { ...mockFormData, @@ -687,9 +687,9 @@ test('should refetch when mixing renderTrigger string control with non-renderTri ], }; - (getChartControlPanelRegistry as any).mockReturnValue({ + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ get: jest.fn().mockReturnValue(controlPanelConfig), - }); + } as unknown as ReturnType); const formDataWithZoom = { ...mockFormData, @@ -720,6 +720,435 @@ test('should refetch when mixing renderTrigger string control with non-renderTri }); }); +test('resolves async (202) responses via the injected handleAsyncChartData hook', async () => { + const asyncJob = { + channel_id: 'c1', + job_id: 'j1', + status: 'running', + result_url: '/api/v1/chart/data/abc', + }; + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: asyncJob, + }); + const handleAsyncChartData = jest + .fn() + .mockResolvedValue([{ data: 'async result' }]); + + const { getByTestId } = render( + , + ); + + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(1); + }); + // Delegates the raw response + job metadata (and useLegacyApi + abort signal) + expect(handleAsyncChartData).toHaveBeenCalledWith( + { status: 202 }, + asyncJob, + false, + expect.any(AbortSignal), + ); + // Chart renders once the async data resolves + await waitFor(() => { + expect(getByTestId('super-chart')).toBeInTheDocument(); + }); +}); + +test('errors on async (202) response when no async handler is provided', async () => { + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: { job_id: 'j1', channel_id: 'c1', status: 'running' }, + }); + const onError = jest.fn(); + + const { findByText } = render( + , + ); + + // Fails loudly instead of rendering the job metadata as empty data + expect(await findByText(/async handler/i)).toBeInTheDocument(); + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1); + }); +}); + +test('renders synchronous (200) responses that include a response object', async () => { + mockChartClient.client.post.mockResolvedValue({ + response: { status: 200 } as Response, + json: [{ result: [{ data: 'sync result' }] }], + }); + + const { getByTestId } = render( + , + ); + + await waitFor(() => { + expect(getByTestId('super-chart')).toBeInTheDocument(); + }); + // Synchronous path: no async handler needed, single request + expect(mockChartClient.client.post).toHaveBeenCalledTimes(1); +}); + +test('wraps the legacy async body as { result: [body] } for the async handler', async () => { + const legacyBody = { job_id: 'j1', channel_id: 'c1', status: 'running' }; + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: legacyBody, + }); + // Force the legacy API path for this viz type + jest.mocked(getChartMetadataRegistry).mockReturnValue({ + get: jest.fn().mockReturnValue({ useLegacyApi: true }), + } as unknown as ReturnType); + const handleAsyncChartData = jest + .fn() + .mockResolvedValue([{ data: 'legacy result' }]); + + const { getByTestId } = render( + , + ); + + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(1); + }); + // Legacy body must be wrapped to match the V1 response signature + expect(handleAsyncChartData).toHaveBeenCalledWith( + { status: 202 }, + { result: [legacyBody] }, + true, + expect.any(AbortSignal), + ); + await waitFor(() => { + expect(getByTestId('super-chart')).toBeInTheDocument(); + }); +}); + +test('does not apply a superseded async response over a newer one', async () => { + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: { job_id: 'j', channel_id: 'c' }, + }); + let resolveFirst: (data: unknown) => void = () => {}; + let resolveSecond: (data: unknown) => void = () => {}; + const handleAsyncChartData = jest + .fn() + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveSecond = resolve; + }), + ); + const onLoad = jest.fn(); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(1); + }); + + // A newer request supersedes the first (viz_type change forces a refetch) + const newFormData = { ...mockFormData, viz_type: 'different_chart' }; + rerender( + , + ); + + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(2); + }); + + // Resolve the newer request first, then the stale one + await act(async () => { + resolveSecond([{ data: 'B' }]); + }); + await act(async () => { + resolveFirst([{ data: 'A' }]); + }); + + await waitFor(() => { + expect(onLoad).toHaveBeenCalledWith([{ data: 'B' }]); + }); + // The stale (superseded) response must not overwrite the newer one + expect(onLoad).not.toHaveBeenCalledWith([{ data: 'A' }]); + expect(onLoad).toHaveBeenCalledTimes(1); +}); + +test('preserves the detailed message from an async (array) rejection', async () => { + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: { job_id: 'j', channel_id: 'c' }, + }); + const handleAsyncChartData = jest + .fn() + .mockRejectedValue([{ error: 'Async query failed: table not found' }]); + const onError = jest.fn(); + + const { findByText } = render( + , + ); + + // The detailed message survives instead of collapsing to the generic one + expect(await findByText(/table not found/i)).toBeInTheDocument(); + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].message).toContain('table not found'); + }); +}); + +test('refetches with the latest formData rather than the initial props', async () => { + mockChartClient.client.post.mockResolvedValue({ + response: { status: 200 } as Response, + json: [{ result: [{ data: 'x' }] }], + }); + + const { rerender } = render( + , + ); + await waitFor(() => { + expect(mockChartClient.client.post).toHaveBeenCalledTimes(1); + }); + + // Change a data-affecting control -> triggers a refetch + rerender( + , + ); + await waitFor(() => { + expect(mockChartClient.client.post).toHaveBeenCalledTimes(2); + }); + + // The second request must carry the updated formData, not the initial props + const secondRequestConfig = mockChartClient.client.post.mock.calls[1][0]; + expect(JSON.stringify(secondRequestConfig)).toContain('metric_v2'); + expect(JSON.stringify(secondRequestConfig)).not.toContain('metric_v1'); +}); + +test('does not revert a render-only change when a slow async request resolves', async () => { + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: { job_id: 'j', channel_id: 'c' }, + }); + // color_scheme is a renderTrigger control -> its change does not refetch + jest.mocked(getChartControlPanelRegistry).mockReturnValue({ + get: jest.fn().mockReturnValue({ + controlPanelSections: [ + { + controlSetRows: [ + [{ name: 'color_scheme', config: { renderTrigger: true } }], + ], + }, + ], + }), + } as unknown as ReturnType); + let resolveAsync: (data: unknown) => void = () => {}; + const handleAsyncChartData = jest.fn( + () => + new Promise(resolve => { + resolveAsync = resolve; + }), + ); + + const { rerender, getByTestId } = render( + , + ); + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(1); + }); + + // Render-only change while the async request is still pending (no refetch) + rerender( + , + ); + expect(handleAsyncChartData).toHaveBeenCalledTimes(1); + + // The stale request resolves; it must not revert color_scheme back + await act(async () => { + resolveAsync([{ data: 'd' }]); + }); + + await waitFor(() => { + expect(getByTestId('super-chart')).toHaveTextContent('scheme_two'); + }); + expect(getByTestId('super-chart')).not.toHaveTextContent('scheme_one'); +}); + +test('passes an abort signal to the async handler and aborts it on unmount', async () => { + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: { job_id: 'j', channel_id: 'c' }, + }); + // Typed with a rest param so mock.calls is indexable (the 4th arg is the signal) + const handleAsyncChartData = jest.fn( + (..._args: unknown[]) => new Promise(() => {}), // never resolves + ); + + const { unmount } = render( + , + ); + + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(1); + }); + const signal = handleAsyncChartData.mock.calls[0][3] as AbortSignal; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + + // Unmounting aborts the signal so a signal-aware handler can stop polling + unmount(); + expect(signal.aborted).toBe(true); +}); + +test('suppresses stale error state from a superseded request', async () => { + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: { job_id: 'j', channel_id: 'c' }, + }); + let rejectFirst: (err: unknown) => void = () => {}; + const handleAsyncChartData = jest + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject; + }), + ) + .mockImplementationOnce(() => new Promise(() => {})); // newer request stays pending + const onError = jest.fn(); + + const { rerender } = render( + , + ); + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(1); + }); + + // Supersede the first request (aborts its controller) + rerender( + , + ); + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(2); + }); + + // The stale request now fails; its error must not surface + await act(async () => { + rejectFirst(new Error('stale failure')); + }); + expect(onError).not.toHaveBeenCalled(); +}); + +test('does not publish stale data when switching from chartId to formData mode', async () => { + mockChartClient.loadFormData.mockResolvedValue({ ...mockFormData }); + mockChartClient.client.post.mockResolvedValue({ + response: { status: 202 } as Response, + json: { job_id: 'j', channel_id: 'c' }, + }); + let resolveFirst: (data: unknown) => void = () => {}; + const handleAsyncChartData = jest + .fn() + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce(() => new Promise(() => {})); + const onLoad = jest.fn(); + + // Start in chartId mode + const { rerender } = render( + , + ); + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(1); + }); + + // Switch to direct-formData mode + rerender( + , + ); + await waitFor(() => { + expect(handleAsyncChartData).toHaveBeenCalledTimes(2); + }); + + // The stale chartId-mode request resolves; its data must not be published + await act(async () => { + resolveFirst([{ data: 'stale' }]); + }); + expect(onLoad).not.toHaveBeenCalledWith([{ data: 'stale' }]); +}); + test('should display error message when HTTP request fails with Response object', async () => { const errorBody = JSON.stringify({ message: 'Error: division by zero' }); const mockResponse = new Response(errorBody, { diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx index baae1ae2ce4..05aa3517f3d 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx @@ -18,17 +18,19 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; +import { isEqual } from 'lodash'; import { ParentSize } from '@visx/responsive'; import { t } from '@apache-superset/core/translation'; import { QueryFormData, QueryData, + JsonObject, SupersetClientInterface, buildQueryContext, RequestConfig, getClientErrorObject, + ensureIsArray, } from '../..'; -import type { HandlerFunction } from '../types/Base'; import { Loading } from '../../components/Loading'; import ChartClient from '../clients/ChartClient'; import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton'; @@ -189,6 +191,12 @@ export default function StatefulChart(props: StatefulChartProps) { const chartClientRef = useRef(); const abortControllerRef = useRef(); + // fetchData is memoized with an empty dep list, so it would otherwise close + // over the first render's props. Keep the latest props in a ref so refetches + // (triggered by updated filters/formData/overrides) use current values. + const propsRef = useRef(props); + propsRef.current = props; + // Initialize chart client if (!chartClientRef.current) { chartClientRef.current = new ChartClient({ client: props.client }); @@ -199,20 +207,48 @@ export default function StatefulChart(props: StatefulChartProps) { chartId, formData: propsFormData, formDataOverrides, - onError, - onLoad, chartType, force, timeout, - } = props; + hooks, + } = propsRef.current; // Cancel any in-flight requests if (abortControllerRef.current) { abortControllerRef.current.abort(); } - // Create new abort controller - abortControllerRef.current = new AbortController(); + // Create new abort controller (kept in a local so we can detect when this + // request has been superseded by a newer one, even across async awaits). + const controller = new AbortController(); + abortControllerRef.current = controller; + + // A request is superseded if it was aborted, or if the props changed in a + // data-affecting way since it began - including switching between chartId + // and direct-formData modes. Props are captured during render but the abort + // happens in a passive effect, so the abort signal alone can let a stale + // success or error slip through in the render->effect gap. This mirrors the + // effect's own refetch decision; render-only changes are intentionally not + // treated as superseding. + const isSuperseded = () => { + if (controller.signal.aborted) { + return true; + } + const latest = propsRef.current; + const vizTypeForCompare = latest.formData?.viz_type || latest.chartType; + return ( + latest.chartId !== chartId || + // Deep compare overrides: callers commonly pass a fresh object with the + // same contents each render, which should not count as superseding. + !isEqual(latest.formDataOverrides, formDataOverrides) || + latest.force !== force || + Boolean(propsFormData) !== Boolean(latest.formData) || + (!!propsFormData && + !!latest.formData && + latest.formData !== propsFormData && + shouldRefetchData(propsFormData, latest.formData, vizTypeForCompare)) + ); + }; setStatus('loading'); setError(undefined); @@ -224,7 +260,7 @@ export default function StatefulChart(props: StatefulChartProps) { // Load formData from chartId finalFormData = await chartClientRef.current!.loadFormData( { sliceId: chartId }, - { signal: abortControllerRef.current.signal } as RequestConfig, + { signal: controller.signal } as RequestConfig, ); } else if (propsFormData) { // Use provided formData @@ -267,7 +303,7 @@ export default function StatefulChart(props: StatefulChartProps) { const requestConfig: RequestConfig = { endpoint, - signal: abortControllerRef.current.signal, + signal: controller.signal, ...(timeout && { timeout: timeout * 1000 }), }; @@ -285,39 +321,136 @@ export default function StatefulChart(props: StatefulChartProps) { }; } - const response = await chartClientRef.current!.client.post(requestConfig); - let responseData = Array.isArray(response.json) - ? response.json - : [response.json]; + const clientResponse = + await chartClientRef.current!.client.post(requestConfig); - // Handle the nested result structure from the new API - if (!useLegacyApi && responseData[0]?.result) { - responseData = responseData[0].result; - } - - setStatus('loaded'); - setData(responseData); - setFormData(finalFormData); - - if (onLoad) { - onLoad(responseData); - } - } catch (err) { - // Ignore abort errors - if ((err as Error).name === 'AbortError') { + // A newer request may have started while the POST was in flight; discard + // this stale response so it can't overwrite the newer chart data. + if (isSuperseded()) { return; } - const parsedError = await getClientErrorObject( - err as Parameters[0], - ); - const errorMessage = - parsedError.error || parsedError.message || 'An error occurred'; + const rawResponse = clientResponse.response as Response | undefined; + + let responseData: QueryData[]; + if (rawResponse?.status === 202) { + // With GLOBAL_ASYNC_QUERIES the query is dispatched to a Celery worker + // and the 202 body is job metadata (channel_id, job_id, result_url), + // not chart data. Delegate to the injected handler, which polls the + // async event channel and resolves the cached results. Without a + // handler we fail loudly rather than rendering the job metadata as if + // it were an (empty) result set. + if (!hooks?.handleAsyncChartData) { + throw new Error( + 'Received an async chart data response (HTTP 202) but no async ' + + 'handler was provided, so results cannot be retrieved. Wire up ' + + 'the async handler or disable GLOBAL_ASYNC_QUERIES for this chart.', + ); + } + // The async handler (handleChartDataResponse) expects the V1 chart data + // response signature. The legacy endpoint returns a flat body, so wrap + // it as { result: [body] } exactly like legacyChartDataRequest does for + // the standard chart path; the V1 body is already correctly shaped. + const asyncPayload = useLegacyApi + ? ({ result: [clientResponse.json] } as JsonObject) + : (clientResponse.json as JsonObject); + responseData = ensureIsArray( + await hooks.handleAsyncChartData( + rawResponse, + asyncPayload, + useLegacyApi, + controller.signal, + ), + ); + + // Async results can resolve well after a newer request began polling. + if (isSuperseded()) { + return; + } + } else { + const rows = ( + Array.isArray(clientResponse.json) + ? clientResponse.json + : [clientResponse.json] + ) as JsonObject[]; + + // Handle the nested result structure from the new API + responseData = ( + !useLegacyApi && rows[0]?.result ? rows[0].result : rows + ) as QueryData[]; + } + + // Don't pair this request's data with newer props or fire a stale onLoad + // if it has been superseded (see isSuperseded). + if (isSuperseded()) { + return; + } + + const latestProps = propsRef.current; + setStatus('loaded'); + setData(responseData); + // Render the resolved data with the latest formData so a render-only + // change made while the request was in flight isn't reverted. + setFormData( + latestProps.formData + ? { + ...latestProps.formData, + ...latestProps.formDataOverrides, + viz_type: finalFormData.viz_type, + } + : finalFormData, + ); + + // Read onLoad from the latest props (like setFormData above) so a stale + // callback captured at request start isn't invoked. + if (latestProps.onLoad) { + latestProps.onLoad(responseData); + } + } catch (err) { + // Ignore aborted requests, whether they threw AbortError or were + // superseded by a newer request (including the render->effect gap). + if ((err as Error)?.name === 'AbortError' || isSuperseded()) { + return; + } + + // waitForAsyncData rejects with an array of already-parsed client-error + // objects; unwrap the first element so its detailed message survives. + const rawError = Array.isArray(err) ? err[0] : err; + + let errorMessage: string | undefined; + if ( + rawError && + typeof rawError === 'object' && + !(rawError instanceof Error) && + !(rawError instanceof Response) && + typeof (rawError as { error?: unknown }).error === 'string' + ) { + // Already a parsed client-error object (e.g. from the async handler); + // getClientErrorObject would discard its `error` field, so read it here. + const parsed = rawError as { error?: string; message?: string }; + errorMessage = parsed.error || parsed.message; + } else { + const parsedError = await getClientErrorObject( + rawError as Parameters[0], + ); + errorMessage = parsedError.error || parsedError.message; + } + + const errorObj = new Error(errorMessage || 'An error occurred'); + + // The request may have been superseded while its error response was being + // parsed above (or in the render->effect gap before its abort ran); don't + // set stale error state or call onError in that case. + if (isSuperseded()) { + return; + } - const errorObj = new Error(errorMessage); setStatus('error'); setError(errorObj); + // Read onError from the latest props so a stale callback captured at + // request start isn't invoked. + const { onError } = propsRef.current; if (onError) { onError(errorObj); } @@ -481,7 +614,7 @@ export default function StatefulChart(props: StatefulChartProps) { enableNoResults={enableNoResults} noResults={NoDataComponent && } onRenderSuccess={onRenderSuccess} - onRenderFailure={onRenderFailure as HandlerFunction | undefined} + onRenderFailure={onRenderFailure} hooks={hooks} /> ); diff --git a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts index a66667c5b36..c372a0cc691 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts @@ -66,6 +66,18 @@ type Hooks = { setTooltip?: HandlerFunction; /* handle legend scroll changes */ onLegendScroll?: HandlerFunction; + /** + * Resolve an async chart-data response (HTTP 202 from GLOBAL_ASYNC_QUERIES). + * Injected by the app so components in this package (e.g. Matrixify's + * StatefulChart) can await async results without importing app-level + * async-event middleware. Returns the resolved query results. + */ + handleAsyncChartData?: ( + response: Response, + json: JsonObject, + useLegacyApi?: boolean, + signal?: AbortSignal, + ) => Promise | QueryData[]; } & PlainObject; /** diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx index 65be71c5aff..b192c9cf1f2 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx @@ -56,6 +56,7 @@ import type { Dispatch } from 'redux'; import ChartContextMenu, { ChartContextMenuRef, } from './ChartContextMenu/ChartContextMenu'; +import { handleChartDataResponse } from './chartAction'; // Types for filter values type FilterValue = string | number | boolean | null | undefined; @@ -162,6 +163,15 @@ interface ChartHooks { setDataMask: (dataMask: DataMask) => void; onLegendScroll: (legendIndex: number) => void; onChartStateChange?: (chartState: AgGridChartState) => void; + // Resolve async (HTTP 202 / GLOBAL_ASYNC_QUERIES) chart-data responses for + // self-contained chart components in superset-ui-core (e.g. StatefulChart), + // which cannot import app-level async-event middleware. + handleAsyncChartData?: ( + response: Response, + json: JsonObject, + useLegacyApi?: boolean, + signal?: AbortSignal, + ) => Promise | QueryData[]; } const BLANK = {}; @@ -385,6 +395,10 @@ function ChartRendererComponent({ setDataMask: setDataMaskCallback, onLegendScroll: handleLegendScroll, onChartStateChange, + // Lets self-contained chart components in superset-ui-core (e.g. + // StatefulChart) resolve async (202) chart-data responses without + // depending on app-level async-event middleware. + handleAsyncChartData: handleChartDataResponse, }), [ handleAddFilter, diff --git a/superset-frontend/src/components/Chart/chartAction.ts b/superset-frontend/src/components/Chart/chartAction.ts index 65b94f231b0..2e6854e0791 100644 --- a/superset-frontend/src/components/Chart/chartAction.ts +++ b/superset-frontend/src/components/Chart/chartAction.ts @@ -51,6 +51,7 @@ import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils'; import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig'; import { updateDataMask } from 'src/dataMask/actions'; import { waitForAsyncData } from 'src/middleware/asyncEvent'; +import { ensureAppRoot } from 'src/utils/navigationUtils'; import { safeStringify } from 'src/utils/safeStringify'; import { extendedDayjs } from '@superset-ui/core/utils/dates'; import type { Dispatch, Action, AnyAction } from 'redux'; @@ -699,6 +700,7 @@ export function handleChartDataResponse( response: Response, json: { result: QueryData[] }, useLegacyApi?: boolean, + signal?: AbortSignal, ): Promise | QueryData[] { if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) { // deal with getChartDataRequest transforming the response data @@ -711,13 +713,17 @@ export function handleChartDataResponse( // Query is running asynchronously and we must await the results. // When status is 202, result contains async event data (job_id, channel_id, etc.) // which differs from QueryData. We cast through unknown to handle this safely. + // The optional signal lets a caller (e.g. StatefulChart) cancel the wait + // when its chart is superseded or unmounted, avoiding leaked listeners. if (useLegacyApi) { return waitForAsyncData( result[0] as unknown as Parameters[0], + signal, ) as Promise; } return waitForAsyncData( result as unknown as Parameters[0], + signal, ) as Promise; default: throw new Error( @@ -853,11 +859,40 @@ export function exploreJSON( } if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) { - // In async mode we just pass the raw error response through - return dispatch( - chartUpdateFailed( - [response as JsonObject], - key as string | number, + // `waitForAsyncData` rejects with an already-normalized async-event + // error object (JOB_STATUS.ERROR) or with an array of client error + // objects (cached-data fetch failure). Those carry a usable + // `error`/`errors` field and can be passed straight through. + // Synchronous HTTP failures — e.g. a QueryObjectValidationError + // surfaced by the pre-cache probe in `_run_async` — reject with a + // raw response that still needs parsing, otherwise the chart error + // banner renders a bare "Data error" with no description. + if (Array.isArray(response)) { + return dispatch( + chartUpdateFailed( + response as JsonObject[], + key as string | number, + ), + ); + } + if ( + response != null && + typeof response === 'object' && + !(response instanceof Response) && + ('error' in response || 'errors' in response) + ) { + return dispatch( + chartUpdateFailed( + [response as JsonObject], + key as string | number, + ), + ); + } + return getClientErrorObject( + response as unknown as Parameters[0], + ).then((parsedResponse: JsonObject) => + dispatch( + chartUpdateFailed([parsedResponse], key as string | number), ), ); } @@ -960,7 +995,7 @@ export function redirectSQLLab( requestedQuery: payload, }); } else { - SupersetClient.postForm(redirectUrl, { + SupersetClient.postForm(ensureAppRoot(redirectUrl), { form_data: safeStringify(payload), }); } diff --git a/superset-frontend/src/components/Chart/chartActions.test.ts b/superset-frontend/src/components/Chart/chartActions.test.ts index 30ba44359aa..7bfe8dcc580 100644 --- a/superset-frontend/src/components/Chart/chartActions.test.ts +++ b/superset-frontend/src/components/Chart/chartActions.test.ts @@ -463,6 +463,113 @@ describe('chart actions', () => { expect(addWarningToastSpy).not.toHaveBeenCalled(); addWarningToastSpy.mockRestore(); }); + + // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks + describe('GlobalAsyncQueries error handling', () => { + beforeEach(() => { + ( + global as unknown as { featureFlags: Record } + ).featureFlags = { + [FeatureFlag.GlobalAsyncQueries]: true, + }; + }); + + beforeEach(() => { + // Simulate the server dispatching the query asynchronously so + // handleChartDataResponse delegates to waitForAsyncData. + fetchMock.removeRoute(MOCK_URL); + fetchMock.post( + `glob:*${MOCK_URL}*`, + { status: 202, body: { result: [{ job_id: 'job-1' }] } }, + { name: MOCK_URL }, + ); + }); + + afterEach(() => { + fetchMock.removeRoute(MOCK_URL); + setupDefaultFetchMock(); + }); + + test('dispatches CHART_UPDATE_FAILED with the array as-is when waitForAsyncData rejects with an array of client error objects', async () => { + const clientErrors = [{ error: 'cached-data fetch failed' }]; + waitForAsyncDataStub.mockImplementation(() => + Promise.reject(clientErrors), + ); + + const actionThunk = actions.postChartFormData( + { viz_type: 'my_viz' } as QueryFormData, + false, + undefined, + undefined, + ); + await actionThunk( + dispatch as unknown as actions.ChartThunkDispatch, + mockGetState as unknown as () => actions.RootState, + undefined, + ); + + const updateFailedAction = dispatch.mock.calls.find( + ([action]) => action?.type === actions.CHART_UPDATE_FAILED, + )?.[0]; + expect(updateFailedAction).toBeDefined(); + expect(updateFailedAction.queriesResponse).toEqual(clientErrors); + }); + + test('dispatches CHART_UPDATE_FAILED wrapping the error object when waitForAsyncData rejects with a normalized async-event error', async () => { + const asyncEventError = { error: 'query failed', errors: [] }; + waitForAsyncDataStub.mockImplementation(() => + Promise.reject(asyncEventError), + ); + + const actionThunk = actions.postChartFormData( + { viz_type: 'my_viz' } as QueryFormData, + false, + undefined, + undefined, + ); + await actionThunk( + dispatch as unknown as actions.ChartThunkDispatch, + mockGetState as unknown as () => actions.RootState, + undefined, + ); + + const updateFailedAction = dispatch.mock.calls.find( + ([action]) => action?.type === actions.CHART_UPDATE_FAILED, + )?.[0]; + expect(updateFailedAction).toBeDefined(); + expect(updateFailedAction.queriesResponse).toEqual([asyncEventError]); + }); + + test('dispatches CHART_UPDATE_FAILED with a parsed error when the pre-cache probe rejects with a raw Response', async () => { + const rawResponse = new Response( + JSON.stringify({ message: 'validation failed' }), + { status: 400, statusText: 'Bad Request' }, + ); + waitForAsyncDataStub.mockImplementation(() => + Promise.reject(rawResponse), + ); + + const actionThunk = actions.postChartFormData( + { viz_type: 'my_viz' } as QueryFormData, + false, + undefined, + undefined, + ); + await actionThunk( + dispatch as unknown as actions.ChartThunkDispatch, + mockGetState as unknown as () => actions.RootState, + undefined, + ); + + const updateFailedAction = dispatch.mock.calls.find( + ([action]) => action?.type === actions.CHART_UPDATE_FAILED, + )?.[0]; + expect(updateFailedAction).toBeDefined(); + expect(updateFailedAction.queriesResponse[0].error).toBe( + 'validation failed', + ); + }); + }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks diff --git a/superset-frontend/src/middleware/asyncEvent.test.ts b/superset-frontend/src/middleware/asyncEvent.test.ts index e78e95769b7..909f1887076 100644 --- a/superset-frontend/src/middleware/asyncEvent.test.ts +++ b/superset-frontend/src/middleware/asyncEvent.test.ts @@ -18,7 +18,11 @@ */ import fetchMock from 'fetch-mock'; import WS from 'jest-websocket-mock'; -import { parseErrorJson, isFeatureEnabled } from '@superset-ui/core'; +import { + parseErrorJson, + isFeatureEnabled, + SupersetClient, +} from '@superset-ui/core'; import * as asyncEvent from 'src/middleware/asyncEvent'; jest.mock('@superset-ui/core', () => ({ @@ -524,5 +528,60 @@ describe('asyncEvent middleware', () => { expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(1); expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(0); }); + + test('rejects with AbortError and stops listening when the signal aborts', async () => { + await wsServer.connected; + + const controller = new AbortController(); + const promise = asyncEvent.waitForAsyncData( + asyncPendingEvent, + controller.signal, + ); + const assertion = expect(promise).rejects.toMatchObject({ + name: 'AbortError', + }); + controller.abort(); + await assertion; + + // A late DONE event must not trigger a cached-data fetch: the listener + // was removed on abort, so no leak / stray request. + wsServer.send(JSON.stringify(asyncDoneEvent)); + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(0); + }); + + test('rejects immediately if the signal is already aborted', async () => { + await wsServer.connected; + + const controller = new AbortController(); + controller.abort(); + + await expect( + asyncEvent.waitForAsyncData(asyncPendingEvent, controller.signal), + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + + test('forwards the abort signal to the cached-data download', async () => { + await wsServer.connected; + + const getSpy = jest.spyOn(SupersetClient, 'get'); + const controller = new AbortController(); + + const promise = asyncEvent.waitForAsyncData( + asyncPendingEvent, + controller.signal, + ); + wsServer.send(JSON.stringify(asyncDoneEvent)); + await expect(promise).resolves.toEqual([chartData]); + + // The cached-result download must receive the signal so it can be + // cancelled if the caller aborts mid-fetch. + expect(getSpy).toHaveBeenCalledWith( + expect.objectContaining({ signal: controller.signal }), + ); + getSpy.mockRestore(); + }); }); }); diff --git a/superset-frontend/src/middleware/asyncEvent.ts b/superset-frontend/src/middleware/asyncEvent.ts index b22a34d3f1b..622926a49fa 100644 --- a/superset-frontend/src/middleware/asyncEvent.ts +++ b/superset-frontend/src/middleware/asyncEvent.ts @@ -86,12 +86,14 @@ const removeListener = (id: string) => { const fetchCachedData = async ( asyncEvent: AsyncEvent, + signal?: AbortSignal, ): Promise => { let status = 'success'; let data; try { const { json } = await SupersetClient.get({ endpoint: String(asyncEvent.result_url), + signal, }); data = 'result' in json ? json.result : json; } catch (response) { @@ -102,32 +104,73 @@ const fetchCachedData = async ( return { status, data }; }; -export const waitForAsyncData = async (asyncResponse: AsyncEvent) => +export const waitForAsyncData = async ( + asyncResponse: AsyncEvent, + signal?: AbortSignal, +) => new Promise((resolve, reject) => { const jobId = asyncResponse.job_id; + + let onAbort: (() => void) | undefined; + const cleanup = () => { + removeListener(jobId); + if (onAbort && signal) { + signal.removeEventListener('abort', onAbort); + } + }; + + // Bail immediately if the caller has already aborted (e.g. the chart was + // unmounted before the job started), avoiding a leaked listener. + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + const listener = async (asyncEvent: AsyncEvent) => { switch (asyncEvent.status) { case JOB_STATUS.DONE: { - let { data, status } = await fetchCachedData(asyncEvent); // eslint-disable-line prefer-const + // Forward the signal so the cached-result download is cancelled too if + // the caller aborts mid-fetch, rather than wasting network/processing. + let { data, status } = await fetchCachedData(asyncEvent, signal); // eslint-disable-line prefer-const data = ensureIsArray(data); if (status === 'success') { resolve(data); } else { reject(data); } + // Terminal status: the promise is settled, so fully clean up. + cleanup(); break; } case JOB_STATUS.ERROR: { const err = parseErrorJson(asyncEvent); reject(err); + // Terminal status: the promise is settled, so fully clean up. + cleanup(); break; } default: { - logging.warn('received event with status', asyncEvent.status); + // Non-terminal status (e.g., 'pending', 'running'): keep the listener + // registered so it can receive the eventual terminal event ('done', 'error'). + // Only cleanup happens on terminal states or abort. + logging.info( + 'received non-terminal event with status', + asyncEvent.status, + ); } } - removeListener(jobId); }; + + // When the caller aborts (chart superseded/unmounted), stop listening so the + // listener and its retained closure don't leak and keep the poller busy. + if (signal) { + onAbort = () => { + cleanup(); + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + } + addListener(jobId, listener); }); From bcf0361a919312331da8e2b4aca40902091d914c Mon Sep 17 00:00:00 2001 From: Ramachandran A G <106139410+ag-ramachandran@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:58:37 +0530 Subject: [PATCH 5/7] feat(KustoKQL): Add support for NULL / IS NOT NULL operator (#37890) Co-authored-by: ag-ramachandran Co-authored-by: Joe Li --- superset/db_engine_specs/kusto.py | 111 +++++++- .../unit_tests/db_engine_specs/test_kusto.py | 85 ++++++ tests/unit_tests/sql/parse_tests.py | 268 ++++++++++++++++++ 3 files changed, 460 insertions(+), 4 deletions(-) diff --git a/superset/db_engine_specs/kusto.py b/superset/db_engine_specs/kusto.py index 7177cd13fb2..85b563262bd 100644 --- a/superset/db_engine_specs/kusto.py +++ b/superset/db_engine_specs/kusto.py @@ -14,11 +14,12 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import logging import re from datetime import datetime -from typing import Any, Optional +from typing import Any, Optional, TYPE_CHECKING -from sqlalchemy import types +from sqlalchemy import func, types from sqlalchemy.dialects.mssql.base import SMALLDATETIME from superset.constants import TimeGrain @@ -28,8 +29,45 @@ from superset.db_engine_specs.exceptions import ( SupersetDBAPIOperationalError, SupersetDBAPIProgrammingError, ) -from superset.sql.parse import LimitMethod -from superset.utils.core import GenericDataType + +if TYPE_CHECKING: + from superset.models.core import Database + +from superset.sql.parse import KQLTokenType, LimitMethod, tokenize_kql +from superset.utils.core import FilterOperator, GenericDataType + +logger = logging.getLogger(__name__) + +_OPENING_BRACKET = [ + (KQLTokenType.WORD, "ARRAY"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.OTHER, "["), +] +_CLOSING_BRACKET = [(KQLTokenType.OTHER, "]"), (KQLTokenType.OTHER, ")")] + + +def strip_array_brackets(kql: str) -> str: + """ + Replace ``ARRAY([...])`` wrappers with ``[...]`` using the KQL tokenizer. + + SQLAlchemy sometimes wraps bracket-quoted KQL identifiers in ARRAY(), + which is invalid KQL. This strips the wrapper while preserving the contents. + """ + tokens = tokenize_kql(kql) + + to_remove: set[int] = set() + depth = 0 + for i in range(len(tokens)): + if tokens[i : i + 3] == _OPENING_BRACKET: + to_remove.add(i) + to_remove.add(i + 1) + depth += 1 + elif depth > 0 and tokens[i : i + 2] == _CLOSING_BRACKET: + to_remove.add(i + 1) + depth -= 1 + + tokens = [token for i, token in enumerate(tokens) if i not in to_remove] + return "".join(val for _, val in tokens) class KustoSqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method @@ -190,6 +228,14 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method type_code_map: dict[int, str] = {} # loaded from get_datatype only if needed + column_type_mappings = ( + ( + re.compile(r"^array.*", re.IGNORECASE), + types.String(), + GenericDataType.STRING, + ), + ) + @classmethod def get_dbapi_exception_mapping(cls) -> dict[type[Exception], type[Exception]]: # pylint: disable=import-outside-toplevel,import-error @@ -201,6 +247,44 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method kusto_exceptions.ProgrammingError: SupersetDBAPIProgrammingError, } + @classmethod + def handle_null_filter( + cls, + sqla_col: Any, + op: FilterOperator, + ) -> Any: + """ + Handle null/not null filter operations for KQL. + + In KQL, null checks use functions: + - isnull(col) for IS NULL + - isnotnull(col) for IS NOT NULL + + :param sqla_col: SQLAlchemy column element + :param op: Filter operator (IS_NULL or IS_NOT_NULL) + :return: SQLAlchemy expression for the null filter + """ + if op == FilterOperator.IS_NULL: + return func.isnull(sqla_col) + if op == FilterOperator.IS_NOT_NULL: + return func.isnotnull(sqla_col) + + raise ValueError(f"Invalid null filter operator: {op}") + + @classmethod + def epoch_to_dttm(cls) -> str: + """ + Convert from number of seconds since the epoch to a timestamp. + """ + return "unixtime_seconds_todatetime({col})" + + @classmethod + def epoch_ms_to_dttm(cls) -> str: + """ + Convert from number of milliseconds since the epoch to a timestamp. + """ + return "unixtime_milliseconds_todatetime({col})" + @classmethod def convert_dttm( cls, target_type: str, dttm: datetime, db_extra: Optional[dict[str, Any]] = None @@ -213,3 +297,22 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method return f"""datetime({dttm.isoformat(timespec="microseconds")})""" return None + + @classmethod + def execute( + cls, + cursor: Any, + query: str, + database: "Database", + **kwargs: Any, + ) -> None: + """ + Execute a KQL query, fixing ARRAY() wrappers around + bracket-quoted identifiers. + + Example: + ARRAY(["age"]) -> ["age"] + ARRAY(["user_name"]) -> ["user_name"] + """ + processed_query = strip_array_brackets(query) + super().execute(cursor, processed_query, database, **kwargs) diff --git a/tests/unit_tests/db_engine_specs/test_kusto.py b/tests/unit_tests/db_engine_specs/test_kusto.py index a21e82a5676..568e90153a8 100644 --- a/tests/unit_tests/db_engine_specs/test_kusto.py +++ b/tests/unit_tests/db_engine_specs/test_kusto.py @@ -139,3 +139,88 @@ def test_timegrain_expressions(in_duration: str, expected_result: str) -> None: col=col, pdf=None, time_grain=in_duration ) assert str(actual_result) == expected_result + + +def test_epoch_to_dttm() -> None: + """ + Test that KQL engine spec returns correct epoch to datetime conversion template. + """ + result = KustoKqlEngineSpec.epoch_to_dttm() + assert result == "unixtime_seconds_todatetime({col})" + + +def test_epoch_ms_to_dttm() -> None: + """ + Test that KQL engine spec returns correct epoch milliseconds to + datetime conversion template. + """ + result = KustoKqlEngineSpec.epoch_ms_to_dttm() + assert result == "unixtime_milliseconds_todatetime({col})" + + +def test_handle_null_filter() -> None: + """ + Test that KQL engine spec uses isnull/isnotnull functions for null filters. + """ + from superset.utils.core import FilterOperator + + test_col = column("test_column") + + # Test IS_NULL - should return isnull(col) + result_null = KustoKqlEngineSpec.handle_null_filter( + test_col, FilterOperator.IS_NULL + ) + assert str(result_null) == "isnull(test_column)" + + # Test IS_NOT_NULL - should return isnotnull(col) + result_not_null = KustoKqlEngineSpec.handle_null_filter( + test_col, FilterOperator.IS_NOT_NULL + ) + assert str(result_not_null) == "isnotnull(test_column)" + + # Test invalid operator - should raise ValueError + with pytest.raises(ValueError, match="Invalid null filter operator"): + KustoKqlEngineSpec.handle_null_filter(test_col, "INVALID_OPERATOR") # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("raw_query", "expected_query"), + [ + ( + 'database("superset").["FreeCodeCamp"] | extend ["age"] = ARRAY(["age"]) ' + '| project ["age"] | take 100', + 'database("superset").["FreeCodeCamp"] | extend ["age"] = ["age"] ' + '| project ["age"] | take 100', + ), + ( + 'database("superset").["FreeCodeCamp"] | project ["age"] | take 100', + 'database("superset").["FreeCodeCamp"] | project ["age"] | take 100', + ), + ( + 'database("superset").["VideoGameSales"]' + ' | where ["rank"]<= 25' + ' | summarize ["SUM(Global_Sales)"] = sum(["global_sales"])' + ' by ["publisher"]' + ' | project ["publisher"], ["SUM(Global_Sales)"]' + ' | order by ["SUM(Global_Sales)"] desc' + " | take 50000", + 'database("superset").["VideoGameSales"]' + ' | where ["rank"]<= 25' + ' | summarize ["SUM(Global_Sales)"] = sum(["global_sales"])' + ' by ["publisher"]' + ' | project ["publisher"], ["SUM(Global_Sales)"]' + ' | order by ["SUM(Global_Sales)"] desc' + " | take 50000", + ), + ], +) +def test_kql_execute_array_processing(raw_query: str, expected_query: str) -> None: + """Ensure `execute` replaces ARRAY wrappers and leaves other queries unchanged.""" + from unittest.mock import Mock + + mock_cursor = Mock() + mock_db = Mock() + + KustoKqlEngineSpec.execute(mock_cursor, raw_query, mock_db) + + mock_cursor.execute.assert_called_once_with(expected_query) diff --git a/tests/unit_tests/sql/parse_tests.py b/tests/unit_tests/sql/parse_tests.py index 206ebc6ea72..bec1849a196 100644 --- a/tests/unit_tests/sql/parse_tests.py +++ b/tests/unit_tests/sql/parse_tests.py @@ -4121,6 +4121,274 @@ def test_kustokql_statement_check_tables_present() -> None: ), ("'test'", [(KQLTokenType.STRING, "'test'")]), ("```test```", [(KQLTokenType.STRING, "```test```")]), + # Double-quoted strings + ('"hello"', [(KQLTokenType.STRING, '"hello"')]), + # Single-quoted string with escaped quote + ( + "'it\\'s a test'", + [(KQLTokenType.STRING, "'it\\'s a test'")], + ), + # Double-quoted string with escaped quote + ( + '"say \\"hi\\""', + [(KQLTokenType.STRING, '"say \\"hi\\""')], + ), + # Semicolon token + ( + "a; b", + [ + (KQLTokenType.WORD, "a"), + (KQLTokenType.SEMICOLON, ";"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "b"), + ], + ), + # Semicolon inside string is not a SEMICOLON token + ( + "'a;b'", + [(KQLTokenType.STRING, "'a;b'")], + ), + # Numbers + ( + "42", + [(KQLTokenType.NUMBER, "42")], + ), + # Other/punctuation tokens + ( + "()", + [ + (KQLTokenType.OTHER, "("), + (KQLTokenType.OTHER, ")"), + ], + ), + # Empty input + ("", []), + # ARRAY bracket pattern used in Kusto engine spec + ( + 'ARRAY(["age"])', + [ + (KQLTokenType.WORD, "ARRAY"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.OTHER, "["), + (KQLTokenType.STRING, '"age"'), + (KQLTokenType.OTHER, "]"), + (KQLTokenType.OTHER, ")"), + ], + ), + # Mixed identifiers, operators, and strings + ( + "tbl | where name == 'Alice' | take 5", + [ + (KQLTokenType.WORD, "tbl"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "|"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "where"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "name"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "="), + (KQLTokenType.OTHER, "="), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.STRING, "'Alice'"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "|"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "take"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.NUMBER, "5"), + ], + ), + # Underscore in identifiers + ( + "my_table", + [(KQLTokenType.WORD, "my_table")], + ), + # Identifiers starting with underscore + ( + "_col1", + [(KQLTokenType.WORD, "_col1")], + ), + # Multiline string with semicolons and quotes + ( + "```select 'x'; drop```", + [(KQLTokenType.STRING, "```select 'x'; drop```")], + ), + # Adjacent strings without whitespace + ( + "'a''b'", + [ + (KQLTokenType.STRING, "'a'"), + (KQLTokenType.STRING, "'b'"), + ], + ), + # Dot operator + ( + "db.table", + [ + (KQLTokenType.WORD, "db"), + (KQLTokenType.OTHER, "."), + (KQLTokenType.WORD, "table"), + ], + ), + # Bracket-quoted identifier (KQL style) + ( + '["column name"]', + [ + (KQLTokenType.OTHER, "["), + (KQLTokenType.STRING, '"column name"'), + (KQLTokenType.OTHER, "]"), + ], + ), + # Whitespace variants (tab, newline) + ( + "a\t\nb", + [ + (KQLTokenType.WORD, "a"), + (KQLTokenType.WHITESPACE, "\t\n"), + (KQLTokenType.WORD, "b"), + ], + ), + # Summarize with count aggregation + ( + "T | summarize count() by State", + [ + (KQLTokenType.WORD, "T"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "|"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "summarize"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "count"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.OTHER, ")"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "by"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "State"), + ], + ), + # Aliased aggregation with avg + ( + "T | summarize avg_val = avg(price) by category", + [ + (KQLTokenType.WORD, "T"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "|"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "summarize"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "avg_val"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "="), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "avg"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.WORD, "price"), + (KQLTokenType.OTHER, ")"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "by"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "category"), + ], + ), + # Multiple aggregations with dcount + ( + "T | summarize cnt = count(), uniq = dcount(user_id)", + [ + (KQLTokenType.WORD, "T"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "|"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "summarize"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "cnt"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "="), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "count"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.OTHER, ")"), + (KQLTokenType.OTHER, ","), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "uniq"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "="), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "dcount"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.WORD, "user_id"), + (KQLTokenType.OTHER, ")"), + ], + ), + # Summarize with bin time bucketing + ( + "T | summarize count() by bin(ts, 1h)", + [ + (KQLTokenType.WORD, "T"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "|"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "summarize"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "count"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.OTHER, ")"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "by"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "bin"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.WORD, "ts"), + (KQLTokenType.OTHER, ","), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.NUMBER, "1"), + (KQLTokenType.WORD, "h"), + (KQLTokenType.OTHER, ")"), + ], + ), + ( + "T | summarize dcountif(user_id, status == 'active') by region", + [ + (KQLTokenType.WORD, "T"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "|"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "summarize"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "dcountif"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.WORD, "user_id"), + (KQLTokenType.OTHER, ","), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "status"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "="), + (KQLTokenType.OTHER, "="), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.STRING, "'active'"), + (KQLTokenType.OTHER, ")"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "by"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "region"), + ], + ), + ( + "T | project tostring(value)", + [ + (KQLTokenType.WORD, "T"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.OTHER, "|"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "project"), + (KQLTokenType.WHITESPACE, " "), + (KQLTokenType.WORD, "tostring"), + (KQLTokenType.OTHER, "("), + (KQLTokenType.WORD, "value"), + (KQLTokenType.OTHER, ")"), + ], + ), ], ) def test_tokenize_kql(kql: str, expected: list[tuple[KQLTokenType, str]]) -> None: From 921f75d5444880a7f10eec0a62773ff815fe7664 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:21:17 -0700 Subject: [PATCH 6/7] chore(deps): bump pillow from 12.2.0 to 12.3.0 (#42348) Signed-off-by: dependabot[bot] Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Joe Li --- requirements/base.txt | 2 +- requirements/development.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 9c809bc5243..13980c542a4 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -285,7 +285,7 @@ parsedatetime==2.6 # via apache-superset (pyproject.toml) pgsanity==0.2.9 # via apache-superset (pyproject.toml) -pillow==12.2.0 +pillow==12.3.0 # via apache-superset (pyproject.toml) platformdirs==4.3.8 # via requests-cache diff --git a/requirements/development.txt b/requirements/development.txt index 20722f936e9..58d064aa17f 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -665,7 +665,7 @@ pgsanity==0.2.9 # via # -c requirements/base-constraint.txt # apache-superset -pillow==12.2.0 +pillow==12.3.0 # via # -c requirements/base-constraint.txt # apache-superset From 49f4e84b486b9c75ea6ed3cf455adf8ab206cbb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:21:29 -0700 Subject: [PATCH 7/7] chore(deps): bump flask-compress from 1.17 to 1.24 (#42346) Signed-off-by: dependabot[bot] Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Joe Li --- requirements/base.txt | 6 +++--- requirements/development.txt | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 13980c542a4..10d8820f469 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -28,6 +28,8 @@ babel==2.17.0 # via flask-babel backoff==2.2.1 # via apache-superset (pyproject.toml) +backports-zstd==1.6.0 + # via flask-compress bcrypt==4.3.0 # via paramiko billiard==4.2.1 @@ -130,7 +132,7 @@ flask-babel==3.1.0 # via flask-appbuilder flask-caching==2.3.1 # via apache-superset (pyproject.toml) -flask-compress==1.17 +flask-compress==1.24 # via apache-superset (pyproject.toml) flask-cors==6.0.5 # via apache-superset (pyproject.toml) @@ -496,5 +498,3 @@ xlsxwriter==3.2.9 # via # apache-superset (pyproject.toml) # pandas -zstandard==0.23.0 - # via flask-compress diff --git a/requirements/development.txt b/requirements/development.txt index 58d064aa17f..a4d54ec450d 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -64,6 +64,10 @@ backoff==2.2.1 # apache-superset backports-tarfile==1.2.0 # via jaraco-context +backports-zstd==1.6.0 + # via + # -c requirements/base-constraint.txt + # flask-compress bcrypt==4.3.0 # via # -c requirements/base-constraint.txt @@ -276,7 +280,7 @@ flask-caching==2.3.1 # via # -c requirements/base-constraint.txt # apache-superset -flask-compress==1.17 +flask-compress==1.24 # via # -c requirements/base-constraint.txt # apache-superset @@ -1168,7 +1172,4 @@ zope-event==5.0 zope-interface==5.4.0 # via gevent zstandard==0.23.0 - # via - # -c requirements/base-constraint.txt - # flask-compress - # trino + # via trino