Merge branch 'master' into remove-legacy-viz-pipeline

Resolves StatefulChart/chartAction conflicts by keeping master's async
(202) handling and staleness guards with the legacy explore_json paths
stripped: the endpoint is always /api/v1/chart/data, the async handler
loses its useLegacyApi parameter, and the legacy body-wrapping branch
and its test are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-07-23 19:45:23 -07:00
30 changed files with 1587 additions and 115 deletions

View File

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

View File

@@ -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)
@@ -285,7 +287,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
@@ -496,5 +498,3 @@ xlsxwriter==3.2.9
# via
# apache-superset (pyproject.toml)
# pandas
zstandard==0.23.0
# via flask-compress

View File

@@ -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
@@ -665,7 +669,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
@@ -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

View File

@@ -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,17 +67,17 @@ beforeEach(() => {
jest.clearAllMocks();
// Setup default registry mocks
(getChartMetadataRegistry as any).mockReturnValue({
jest.mocked(getChartMetadataRegistry).mockReturnValue({
get: jest.fn().mockReturnValue({}),
});
} as unknown as ReturnType<typeof getChartMetadataRegistry>);
(getChartBuildQueryRegistry as any).mockReturnValue({
jest.mocked(getChartBuildQueryRegistry).mockReturnValue({
get: jest.fn().mockResolvedValue(null),
});
} as unknown as ReturnType<typeof getChartBuildQueryRegistry>);
(getChartControlPanelRegistry as any).mockReturnValue({
jest.mocked(getChartControlPanelRegistry).mockReturnValue({
get: jest.fn().mockReturnValue(null),
});
} as unknown as ReturnType<typeof getChartControlPanelRegistry>);
// Mock ChartClient constructor
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
@@ -111,9 +111,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<typeof getChartControlPanelRegistry>);
const { rerender } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -163,9 +163,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<typeof getChartControlPanelRegistry>);
const { rerender, getByTestId } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -199,9 +199,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<typeof getChartControlPanelRegistry>);
const { rerender } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -243,9 +243,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<typeof getChartControlPanelRegistry>);
const { rerender } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -297,9 +297,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<typeof getChartControlPanelRegistry>);
const { rerender } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -350,9 +350,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<typeof getChartControlPanelRegistry>);
const { rerender, getByTestId } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -402,11 +402,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<typeof getChartControlPanelRegistry>);
const { rerender } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -490,9 +490,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<typeof getChartControlPanelRegistry>);
const formDataWithZoom = {
...mockFormData,
@@ -540,9 +540,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<typeof getChartControlPanelRegistry>);
const { rerender, getByTestId } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -583,9 +583,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<typeof getChartControlPanelRegistry>);
const { rerender } = render(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -629,9 +629,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<typeof getChartControlPanelRegistry>);
const formDataWithControls = {
...mockFormData,
@@ -685,9 +685,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<typeof getChartControlPanelRegistry>);
const formDataWithZoom = {
...mockFormData,
@@ -718,6 +718,397 @@ 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(
<StatefulChart
formData={mockFormData}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
/>,
);
await waitFor(() => {
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
});
// Delegates the raw response + job metadata (and the abort signal)
expect(handleAsyncChartData).toHaveBeenCalledWith(
{ status: 202 },
asyncJob,
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(
<StatefulChart
formData={mockFormData}
chartType="test_chart"
onError={onError}
/>,
);
// 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(
<StatefulChart formData={mockFormData} chartType="test_chart" />,
);
await waitFor(() => {
expect(getByTestId('super-chart')).toBeInTheDocument();
});
// Synchronous path: no async handler needed, single request
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
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(
<StatefulChart
formData={mockFormData}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
onLoad={onLoad}
/>,
);
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(
<StatefulChart
formData={newFormData}
chartType="different_chart"
hooks={{ handleAsyncChartData }}
onLoad={onLoad}
/>,
);
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(
<StatefulChart
formData={mockFormData}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
onError={onError}
/>,
);
// 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(
<StatefulChart
formData={{ ...mockFormData, metrics: ['metric_v1'] }}
chartType="test_chart"
/>,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
// Change a data-affecting control -> triggers a refetch
rerender(
<StatefulChart
formData={{ ...mockFormData, metrics: ['metric_v2'] }}
chartType="test_chart"
/>,
);
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<typeof getChartControlPanelRegistry>);
let resolveAsync: (data: unknown) => void = () => {};
const handleAsyncChartData = jest.fn(
() =>
new Promise(resolve => {
resolveAsync = resolve;
}),
);
const { rerender, getByTestId } = render(
<StatefulChart
formData={{ ...mockFormData, color_scheme: 'scheme_one' }}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
/>,
);
await waitFor(() => {
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
});
// Render-only change while the async request is still pending (no refetch)
rerender(
<StatefulChart
formData={{ ...mockFormData, color_scheme: 'scheme_two' }}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
/>,
);
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>(() => {}), // never resolves
);
const { unmount } = render(
<StatefulChart
formData={mockFormData}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
/>,
);
await waitFor(() => {
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
});
const signal = handleAsyncChartData.mock.calls[0][2] 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(
<StatefulChart
formData={mockFormData}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
onError={onError}
/>,
);
await waitFor(() => {
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
});
// Supersede the first request (aborts its controller)
rerender(
<StatefulChart
formData={{ ...mockFormData, viz_type: 'different_chart' }}
chartType="different_chart"
hooks={{ handleAsyncChartData }}
onError={onError}
/>,
);
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(
<StatefulChart
chartId={1}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
onLoad={onLoad}
/>,
);
await waitFor(() => {
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
});
// Switch to direct-formData mode
rerender(
<StatefulChart
formData={{ ...mockFormData, metrics: ['m'] }}
chartType="test_chart"
hooks={{ handleAsyncChartData }}
onLoad={onLoad}
/>,
);
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, {

View File

@@ -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';
@@ -188,6 +190,12 @@ export default function StatefulChart(props: StatefulChartProps) {
const chartClientRef = useRef<ChartClient>();
const abortControllerRef = useRef<AbortController>();
// 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 });
@@ -198,20 +206,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);
@@ -223,7 +259,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
@@ -262,7 +298,7 @@ export default function StatefulChart(props: StatefulChartProps) {
const requestConfig: RequestConfig = {
endpoint: '/api/v1/chart/data',
signal: abortControllerRef.current.signal,
signal: controller.signal,
...(timeout && { timeout: timeout * 1000 }),
jsonPayload: {
...queryContext,
@@ -270,39 +306,126 @@ 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 API
if (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<typeof getClientErrorObject>[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.',
);
}
responseData = ensureIsArray(
await hooks.handleAsyncChartData(
rawResponse,
clientResponse.json as JsonObject,
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 API
responseData = (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<typeof getClientErrorObject>[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);
}
@@ -466,7 +589,7 @@ export default function StatefulChart(props: StatefulChartProps) {
enableNoResults={enableNoResults}
noResults={NoDataComponent && <NoDataComponent />}
onRenderSuccess={onRenderSuccess}
onRenderFailure={onRenderFailure as HandlerFunction | undefined}
onRenderFailure={onRenderFailure}
hooks={hooks}
/>
);

View File

@@ -66,6 +66,17 @@ 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,
signal?: AbortSignal,
) => Promise<QueryData[]> | QueryData[];
} & PlainObject;
/**

View File

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

View File

@@ -899,7 +899,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)
@@ -937,12 +952,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 },

View File

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

View File

@@ -1519,6 +1519,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 },
@@ -1541,6 +1580,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)', () => {

View File

@@ -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,14 @@ 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,
signal?: AbortSignal,
) => Promise<QueryData[]> | QueryData[];
}
const BLANK = {};
@@ -385,6 +394,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,

View File

@@ -49,6 +49,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';
@@ -638,6 +639,7 @@ export function addChart(
export function handleChartDataResponse(
response: Response,
json: { result: QueryData[] },
signal?: AbortSignal,
): Promise<QueryData[]> | QueryData[] {
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
// deal with getChartDataRequest transforming the response data
@@ -650,8 +652,11 @@ 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.
return waitForAsyncData(
result as unknown as Parameters<typeof waitForAsyncData>[0],
signal,
) as Promise<QueryData[]>;
default:
throw new Error(
@@ -783,11 +788,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<typeof getClientErrorObject>[0],
).then((parsedResponse: JsonObject) =>
dispatch(
chartUpdateFailed([parsedResponse], key as string | number),
),
);
}
@@ -872,7 +906,7 @@ export function redirectSQLLab(
requestedQuery: payload,
});
} else {
SupersetClient.postForm(redirectUrl, {
SupersetClient.postForm(ensureAppRoot(redirectUrl), {
form_data: safeStringify(payload),
});
}

View File

@@ -462,6 +462,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<string, boolean> }
).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

View File

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

View File

@@ -86,12 +86,14 @@ const removeListener = (id: string) => {
const fetchCachedData = async (
asyncEvent: AsyncEvent,
signal?: AbortSignal,
): Promise<CachedDataResponse> => {
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);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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