mirror of
https://github.com/apache/superset.git
synced 2026-08-04 04:52:32 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user