mirror of
https://github.com/apache/superset.git
synced 2026-08-25 01:21:18 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93ddafa6b5 | ||
|
|
dfd057b920 | ||
|
|
52571a5b8b | ||
|
|
ca3d7670b7 | ||
|
|
94855e9626 | ||
|
|
7dbf71a379 | ||
|
|
24b95f9ca7 | ||
|
|
c635d0754f |
@@ -105,6 +105,7 @@ jobs:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ bigquery = [
|
||||
"sqlalchemy-bigquery>=1.17.2",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=1.7.1, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
|
||||
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
|
||||
|
||||
@@ -42,6 +42,7 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
|
||||
PATTERNS = {
|
||||
"python": [
|
||||
r"^\.github/workflows/.*python",
|
||||
r"^\.github/workflows/frontend-bundle-size-nightly\.yml$",
|
||||
r"^\.github/workflows/scheduled-docker-image-refresh\.yml$",
|
||||
r"^docker-compose-image-tag\.yml$",
|
||||
r"^tests/",
|
||||
|
||||
Generated
+14
-14
@@ -45,9 +45,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.7.1",
|
||||
"@rjsf/core": "^6.8.0",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.7.1",
|
||||
"@rjsf/validator-ajv8": "^6.8.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -10340,9 +10340,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rjsf/core": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.7.1.tgz",
|
||||
"integrity": "sha512-/CQfIGUzcXceBNRhEH3wsTvxcT8dMrjPLXhYSfcJUTftKxOCdisqi2wFwt7LOyIvFvzHUxag0r0xXs/MXS9jmA==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.8.0.tgz",
|
||||
"integrity": "sha512-HZ2e/l/QNcz8PTslBXyGWkiycMe3LgIfwgXo0qO3DPgyFKj4G+obJTKBq/W+DaIUmz1HicvH5WtOdXWWqK+gbA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1",
|
||||
@@ -10354,19 +10354,19 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.7.1",
|
||||
"@rjsf/utils": "^6.8.0",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/utils": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.7.1.tgz",
|
||||
"integrity": "sha512-6goBapMwyHcXvjLkCnFs4S3P1oKUi1H083BdPk4pDZALFWn5ZdG50ECNfHSddBmL3O0pyx1/WFq1C/MuR7Y54A==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.8.0.tgz",
|
||||
"integrity": "sha512-gHcqPFSHdOz29tZiLlzDvD+Gfq21zVIFBufprSYTiHpUdcVNJEK6+V5aw++FtQncHdDWnTf2YCky/e1Bol2NEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@x0k/json-schema-merge": "^1.0.3",
|
||||
"fast-equals": "^6.0.0",
|
||||
"fast-uri": "^4.1.1",
|
||||
"fast-uri": "^4.1.2",
|
||||
"jsonpointer": "^5.0.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
@@ -10380,9 +10380,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/validator-ajv8": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.7.1.tgz",
|
||||
"integrity": "sha512-oG9reR8VgUUTxfsO8WybZWTjKs6SLUdhmUCp55SXmJvwVbeKZ+Mz4SI+y+T1Mdpbm1kLZWQPRyF2Md97soWXkw==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.8.0.tgz",
|
||||
"integrity": "sha512-F36I952/miMFZzWSlupwFHbl+j+5bVQ3tR6HtBS+vXV10kY5dT3OwTjbFRo4fQM1KpqrUZC0t3/E5CZRX1o1jA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
@@ -10394,7 +10394,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.7.1"
|
||||
"@rjsf/utils": "^6.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
|
||||
@@ -122,9 +122,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.7.1",
|
||||
"@rjsf/core": "^6.8.0",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.7.1",
|
||||
"@rjsf/validator-ajv8": "^6.8.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export default function DndAdhocFilterOption({
|
||||
<OptionWrapper
|
||||
key={index}
|
||||
index={index}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
|
||||
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
|
||||
clickClose={onClickClose}
|
||||
onShiftOptions={onShiftOptions}
|
||||
|
||||
+30
-1
@@ -43,7 +43,7 @@ import {
|
||||
DndFilterSelectProps,
|
||||
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
|
||||
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
|
||||
import { ExpressionTypes } from '../FilterControl/types';
|
||||
import { Clauses, ExpressionTypes } from '../FilterControl/types';
|
||||
import { DndItemType } from '../../DndItemType';
|
||||
import { Datasource } from '../../../types';
|
||||
import {
|
||||
@@ -137,6 +137,35 @@ test('renders with value', async () => {
|
||||
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the pill using the column verbose_name when one is set', async () => {
|
||||
const value = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'num',
|
||||
operator: '>',
|
||||
comparator: '500',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
render(
|
||||
setup({
|
||||
value,
|
||||
columns: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'BIGINT',
|
||||
type_generic: GenericDataType.Numeric,
|
||||
column_name: 'num',
|
||||
verbose_name: 'total_count',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
useDndKit: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
expect(await screen.findByText('total_count > 500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders options with saved metric', async () => {
|
||||
render(
|
||||
setup({
|
||||
|
||||
+28
@@ -370,4 +370,32 @@ describe('AdhocFilter', () => {
|
||||
});
|
||||
expect(adhocFilter.getDefaultLabel()).toBe('');
|
||||
});
|
||||
test('uses the column verbose_name in the label when one is given', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'num',
|
||||
operator: '>',
|
||||
comparator: '500',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(
|
||||
adhocFilter.getDefaultLabel([
|
||||
{ column_name: 'num', verbose_name: 'total_count' },
|
||||
]),
|
||||
).toBe('total_count > 500');
|
||||
});
|
||||
test('falls back to the column_name when no verbose_name is set', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'num',
|
||||
operator: '>',
|
||||
comparator: '500',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(
|
||||
adhocFilter.getDefaultLabel([{ column_name: 'num', verbose_name: '' }]),
|
||||
).toBe('num > 500');
|
||||
expect(adhocFilter.getDefaultLabel([])).toBe('num > 500');
|
||||
expect(adhocFilter.getDefaultLabel()).toBe('num > 500');
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -23,7 +23,7 @@ import {
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||
Operators,
|
||||
} from 'src/explore/constants';
|
||||
import { translateToSql } from '../utils/translateToSQL';
|
||||
import { translateToSql, VerboseColumn } from '../utils/translateToSQL';
|
||||
import { Clauses, ExpressionTypes } from '../types';
|
||||
|
||||
const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
|
||||
@@ -193,8 +193,8 @@ export default class AdhocFilter {
|
||||
);
|
||||
}
|
||||
|
||||
getDefaultLabel(): string {
|
||||
const label = this.translateToSql();
|
||||
getDefaultLabel(columns?: VerboseColumn[]): string {
|
||||
const label = this.translateToSql({ columns });
|
||||
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
|
||||
}
|
||||
|
||||
@@ -202,8 +202,8 @@ export default class AdhocFilter {
|
||||
return this.translateToSql();
|
||||
}
|
||||
|
||||
translateToSql(): string {
|
||||
return translateToSql(this as unknown as CoreAdhocFilter);
|
||||
translateToSql(params: { columns?: VerboseColumn[] } = {}): string {
|
||||
return translateToSql(this as unknown as CoreAdhocFilter, params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -23,6 +23,7 @@ import {
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
@@ -914,3 +915,39 @@ test('dropdown should remain open when clicked after filter is configured', asyn
|
||||
|
||||
expect(operatorDropdown).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('filters the subject select by column verbose_name as well as column_name', async () => {
|
||||
setup({
|
||||
options: [
|
||||
{
|
||||
type: 'BIGINT',
|
||||
column_name: 'num',
|
||||
verbose_name: 'total_count',
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
type: 'VARCHAR(255)',
|
||||
column_name: 'name',
|
||||
verbose_name: 'Full Name',
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const combobox = screen.getByRole('combobox', { name: 'Select subject' });
|
||||
userEvent.click(combobox);
|
||||
|
||||
await userEvent.type(combobox, 'total');
|
||||
|
||||
const dropdown = document.querySelector(
|
||||
'.ant-select-dropdown-list',
|
||||
) as HTMLElement;
|
||||
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(combobox);
|
||||
await userEvent.type(combobox, 'num');
|
||||
|
||||
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
+4
@@ -639,7 +639,11 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
('optionName' in column && column.optionName) ||
|
||||
undefined,
|
||||
label: renderSubjectOptionLabel(column),
|
||||
column_name: 'column_name' in column ? column.column_name : undefined,
|
||||
verbose_name:
|
||||
'verbose_name' in column ? column.verbose_name : undefined,
|
||||
}))}
|
||||
optionFilterProps={['column_name', 'verbose_name']}
|
||||
{...subjectSelectProps}
|
||||
/>
|
||||
);
|
||||
|
||||
+18
@@ -71,6 +71,24 @@ test('should render the control label', async () => {
|
||||
expect(await screen.findByText('value > 10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the control label using the column verbose_name when one is set', async () => {
|
||||
render(
|
||||
setup({
|
||||
...mockedProps,
|
||||
options: [
|
||||
{
|
||||
type: 'DOUBLE',
|
||||
column_name: 'value',
|
||||
verbose_name: 'total_count',
|
||||
id: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ useDnd: true, useRedux: true },
|
||||
);
|
||||
expect(await screen.findByText('total_count > 10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the remove button', async () => {
|
||||
render(setup(mockedProps), { useDnd: true, useRedux: true });
|
||||
const removeBtn = await screen.findByTestId('remove-control-button');
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ export default function AdhocFilterOption({
|
||||
partitionColumn={partitionColumn ?? undefined}
|
||||
>
|
||||
<OptionControlLabel
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
|
||||
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
|
||||
onRemove={() =>
|
||||
onRemoveFilter({
|
||||
|
||||
+32
-2
@@ -63,9 +63,35 @@ export const OPERATORS_TO_SQL = {
|
||||
`= '{{ presto.latest_partition('${datasource.schema}.${datasource.datasource_name}') }}'`,
|
||||
};
|
||||
|
||||
export interface VerboseColumn {
|
||||
column_name?: string;
|
||||
verbose_name?: string | null;
|
||||
}
|
||||
|
||||
// Resolves the display label for a filter's subject: the verbose_name of the
|
||||
// matching column when one is supplied, falling back to the technical
|
||||
// subject used for SQL generation.
|
||||
const getDisplaySubject = (
|
||||
subject: string | { column_name?: string } | null | undefined,
|
||||
columns?: VerboseColumn[],
|
||||
) => {
|
||||
if (!columns) {
|
||||
return subject ?? undefined;
|
||||
}
|
||||
const columnName =
|
||||
typeof subject === 'object' ? subject?.column_name : subject;
|
||||
const verboseName = columns.find(
|
||||
column => column.column_name === columnName,
|
||||
)?.verbose_name;
|
||||
return verboseName || (subject ?? undefined);
|
||||
};
|
||||
|
||||
export const translateToSql = (
|
||||
adhocFilter: AdhocFilter,
|
||||
{ useSimple }: { useSimple: boolean } = { useSimple: false },
|
||||
{
|
||||
useSimple,
|
||||
columns,
|
||||
}: { useSimple?: boolean; columns?: VerboseColumn[] } = {},
|
||||
) => {
|
||||
if (isSimpleAdhocFilter(adhocFilter) || useSimple) {
|
||||
const { subject, operator } = adhocFilter as SimpleAdhocFilter;
|
||||
@@ -81,7 +107,11 @@ export const translateToSql = (
|
||||
OPERATORS_TO_SQL[operator](adhocFilter)
|
||||
: // @ts-expect-error TODO: fix missing operator type `NOT LIKE` and `TEMPORAL RANGE`.
|
||||
OPERATORS_TO_SQL[operator];
|
||||
return getSimpleSQLExpression(subject, op, comparator);
|
||||
return getSimpleSQLExpression(
|
||||
getDisplaySubject(subject, columns),
|
||||
op,
|
||||
comparator,
|
||||
);
|
||||
}
|
||||
if (isFreeFormAdhocFilter(adhocFilter)) {
|
||||
return adhocFilter.sqlExpression;
|
||||
|
||||
+5
-4
@@ -22,10 +22,11 @@ import FixedOrMetricControl from '.';
|
||||
jest.mock(
|
||||
'@superset-ui/core/components/Icons/AsyncIcon',
|
||||
() =>
|
||||
({ fileName }: { fileName: string }) => (
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
({ fileName }: { fileName: string }) =>
|
||||
(
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
);
|
||||
|
||||
const createProps = () => ({
|
||||
|
||||
+5
-4
@@ -38,10 +38,11 @@ import {
|
||||
jest.mock(
|
||||
'@superset-ui/core/components/Icons/AsyncIcon',
|
||||
() =>
|
||||
({ fileName }: { fileName: string }) => (
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
({ fileName }: { fileName: string }) =>
|
||||
(
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
+5
-4
@@ -29,10 +29,11 @@ import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
|
||||
jest.mock(
|
||||
'@superset-ui/core/components/Icons/AsyncIcon',
|
||||
() =>
|
||||
({ fileName }: { fileName: string }) => (
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
({ fileName }: { fileName: string }) =>
|
||||
(
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
|
||||
<span role="img" aria-label={fileName.replace('_', '-')} />
|
||||
),
|
||||
);
|
||||
|
||||
const errorMessageRegistry = getErrorMessageComponentRegistry();
|
||||
|
||||
@@ -43,6 +43,21 @@ function findEndpoint(spy: jest.SpyInstance, substring: string): string {
|
||||
return (match[0] as Record<string, string>).endpoint;
|
||||
}
|
||||
|
||||
function deferredJsonResponse() {
|
||||
let resolveResponse: ((value: JsonResponse) => void) | undefined;
|
||||
let rejectResponse: ((reason?: unknown) => void) | undefined;
|
||||
const promise = new Promise<JsonResponse>((resolve, reject) => {
|
||||
resolveResponse = resolve;
|
||||
rejectResponse = reject;
|
||||
});
|
||||
|
||||
if (!resolveResponse || !rejectResponse) {
|
||||
throw new Error('Deferred response handlers were not initialized');
|
||||
}
|
||||
|
||||
return { promise, resolve: resolveResponse, reject: rejectResponse };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
@@ -282,6 +297,147 @@ test('useListViewResource: fetchData sets loading to true then false', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('useListViewResource: ignores an older response that resolves last', async () => {
|
||||
const older = deferredJsonResponse();
|
||||
const newer = deferredJsonResponse();
|
||||
const toISOString = jest
|
||||
.spyOn(Date.prototype, 'toISOString')
|
||||
.mockReturnValueOnce('newer-response-time')
|
||||
.mockReturnValueOnce('older-response-time');
|
||||
jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Charts', jest.fn(), false),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [],
|
||||
});
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
newer.resolve({
|
||||
json: { result: [], count: 0 },
|
||||
} as unknown as JsonResponse);
|
||||
});
|
||||
expect(result.current.state.resourceCollection).toEqual([]);
|
||||
expect(result.current.state.resourceCount).toBe(0);
|
||||
expect(result.current.state.lastFetched).toBe('newer-response-time');
|
||||
|
||||
await act(async () => {
|
||||
older.resolve({
|
||||
json: { result: [{ id: 1 }, { id: 2 }], count: 2 },
|
||||
} as unknown as JsonResponse);
|
||||
});
|
||||
|
||||
expect(result.current.state.resourceCollection).toEqual([]);
|
||||
expect(result.current.state.resourceCount).toBe(0);
|
||||
expect(result.current.state.lastFetched).toBe('newer-response-time');
|
||||
expect(toISOString).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('useListViewResource: stale completion keeps the latest request loading', async () => {
|
||||
const older = deferredJsonResponse();
|
||||
const newer = deferredJsonResponse();
|
||||
jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Charts', jest.fn(), false),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [],
|
||||
});
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
older.resolve({
|
||||
json: { result: [{ id: 1 }], count: 1 },
|
||||
} as unknown as JsonResponse);
|
||||
});
|
||||
|
||||
expect(result.current.state.resourceCollection).toEqual([]);
|
||||
expect(result.current.state.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
newer.resolve({
|
||||
json: { result: [{ id: 2 }], count: 1 },
|
||||
} as unknown as JsonResponse);
|
||||
});
|
||||
|
||||
expect(result.current.state.resourceCollection).toEqual([{ id: 2 }]);
|
||||
expect(result.current.state.loading).toBe(false);
|
||||
});
|
||||
|
||||
test('useListViewResource: only the latest request reports an error', async () => {
|
||||
const older = deferredJsonResponse();
|
||||
const newer = deferredJsonResponse();
|
||||
const handleErrorMsg = jest.fn();
|
||||
jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Charts', handleErrorMsg, false),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [],
|
||||
});
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'name' }],
|
||||
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
older.reject('older request failed');
|
||||
});
|
||||
|
||||
expect(handleErrorMsg).not.toHaveBeenCalled();
|
||||
expect(result.current.state.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
newer.reject('newer request failed');
|
||||
});
|
||||
|
||||
expect(handleErrorMsg).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.state.loading).toBe(false);
|
||||
});
|
||||
|
||||
test('useListViewResource: refreshData re-fetches with last config', async () => {
|
||||
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [], count: 0 },
|
||||
|
||||
@@ -148,6 +148,7 @@ export function useListViewResource<D extends object = any>(
|
||||
);
|
||||
|
||||
const lastFetchDataConfigRef = useRef<FetchDataConfig | null>(null);
|
||||
const latestRequestIdRef = useRef(0);
|
||||
|
||||
const fetchData = useCallback(
|
||||
({
|
||||
@@ -156,6 +157,9 @@ export function useListViewResource<D extends object = any>(
|
||||
sortBy,
|
||||
filters: filterValues,
|
||||
}: FetchDataConfig) => {
|
||||
const requestId = latestRequestIdRef.current + 1;
|
||||
latestRequestIdRef.current = requestId;
|
||||
const isLatest = () => latestRequestIdRef.current === requestId;
|
||||
const config: FetchDataConfig = {
|
||||
filters: filterValues,
|
||||
pageIndex,
|
||||
@@ -196,24 +200,31 @@ export function useListViewResource<D extends object = any>(
|
||||
})
|
||||
.then(
|
||||
({ json = {} }) => {
|
||||
if (!isLatest()) {
|
||||
return;
|
||||
}
|
||||
updateState({
|
||||
collection: json.result,
|
||||
count: json.count,
|
||||
lastFetched: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
handleErrorMsg(
|
||||
t(
|
||||
'An error occurred while fetching %ss: %s',
|
||||
resourceLabel,
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
createErrorHandler(errMsg => {
|
||||
if (isLatest()) {
|
||||
handleErrorMsg(
|
||||
t(
|
||||
'An error occurred while fetching %ss: %s',
|
||||
resourceLabel,
|
||||
errMsg,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
updateState({ loading: false });
|
||||
if (isLatest()) {
|
||||
updateState({ loading: false });
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
|
||||
@@ -196,13 +196,26 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
# 1. 'metric_name' - name of predefined metric
|
||||
# 2. { label: 'label_name' } - legacy format for a predefined metric
|
||||
# 3. { expressionType: 'SIMPLE' | 'SQL', ... } - adhoc metric
|
||||
def is_str_or_adhoc(metric: Metric) -> bool:
|
||||
return isinstance(metric, str) or is_adhoc_metric(metric)
|
||||
# Keys that only ever appear on an ad-hoc metric definition. A dict
|
||||
# carrying one of these but missing `expressionType` is a malformed
|
||||
# ad-hoc metric, not a legacy predefined-metric reference, and must
|
||||
# not be silently collapsed to its label, which would later be
|
||||
# misread as a request for a saved metric of that name.
|
||||
adhoc_metric_keys = {"sqlExpression", "aggregate", "column"}
|
||||
|
||||
self.metrics = metrics and [
|
||||
x if is_str_or_adhoc(x) else x["label"] # type: ignore
|
||||
for x in metrics
|
||||
]
|
||||
def normalize_metric(metric: Metric) -> Metric:
|
||||
if isinstance(metric, str) or is_adhoc_metric(metric):
|
||||
return metric
|
||||
if adhoc_metric_keys & metric.keys():
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Invalid ad-hoc metric %(label)s: `expressionType` is missing",
|
||||
label=metric.get("label"),
|
||||
)
|
||||
)
|
||||
return metric["label"] # type: ignore
|
||||
|
||||
self.metrics = metrics and [normalize_metric(x) for x in metrics]
|
||||
|
||||
def _set_post_processing(
|
||||
self, post_processing: list[dict[str, Any] | None] | None
|
||||
|
||||
@@ -1316,6 +1316,10 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
try:
|
||||
TestConnectionDatabaseCommand(item).run()
|
||||
return self.response(200, message="OK")
|
||||
except OAuth2RedirectError:
|
||||
# OAuth2 connections pass, so they can be saved. A user later
|
||||
# can then store an OAuth2 token.
|
||||
return self.response(200, message="OK")
|
||||
except (
|
||||
SSHTunnelingNotEnabledError,
|
||||
SSHTunnelDatabasePortError,
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from typing import Any, TYPE_CHECKING, TypedDict
|
||||
|
||||
@@ -32,7 +33,7 @@ from marshmallow.exceptions import ValidationError
|
||||
from requests import Session
|
||||
from shillelagh.adapters.api.gsheets.lib import SCOPES
|
||||
from shillelagh.exceptions import UnauthenticatedError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import text, types
|
||||
from sqlalchemy.engine import create_engine
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
@@ -155,6 +156,27 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
|
||||
oauth2_token_request_uri = "https://oauth2.googleapis.com/token" # noqa: S105
|
||||
oauth2_exception = (UnauthenticatedError, OAuth2TokenRefreshError)
|
||||
|
||||
@classmethod
|
||||
def convert_dttm(
|
||||
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Convert a datetime to a SQL literal understood by shillelagh's GSheets
|
||||
adapter.
|
||||
|
||||
``SqliteEngineSpec.convert_dttm`` (inherited via ``ShillelaghEngineSpec``)
|
||||
has no case for ``types.Date`` and returns ``None``, which makes Superset
|
||||
fall back to a literal that still carries a time-of-day component. The
|
||||
GSheets adapter's virtual table layer parses that literal with
|
||||
``datetime.date.fromisoformat``, which rejects the trailing time and
|
||||
silently drops the filter value, producing an invalid query against the
|
||||
Google Sheets API. A bare ``YYYY-MM-DD`` literal is required instead.
|
||||
"""
|
||||
sqla_type = cls.get_sqla_column_type(target_type)
|
||||
if isinstance(sqla_type, types.Date):
|
||||
return f"'{dttm.date().isoformat()}'"
|
||||
return super().convert_dttm(target_type, dttm, db_extra=db_extra)
|
||||
|
||||
@classmethod
|
||||
def get_oauth2_authorization_uri(
|
||||
cls,
|
||||
|
||||
@@ -2403,6 +2403,54 @@ class TestDatabaseApi(SupersetTestCase):
|
||||
assert rv.status_code == 200
|
||||
assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
|
||||
|
||||
@with_config({"PREVENT_UNSAFE_DB_CONNECTIONS": False})
|
||||
def test_test_connection_oauth2(self):
|
||||
"""
|
||||
Database API: Test test connection flow with a connection authenticated via
|
||||
OAuth2.
|
||||
|
||||
The test would always raise ``OAuth2RedirectError``, and we can't start the
|
||||
OAuth2 dance before the connection is saved, so it should return a 200 status.
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
example_db = get_example_database()
|
||||
masked_encrypted_extra = json.dumps(
|
||||
{
|
||||
"oauth2_client_info": {
|
||||
"id": "client_id",
|
||||
"secret": "client_secret",
|
||||
"scope": "some-scope",
|
||||
"authorization_request_uri": "https://example.org/authorize",
|
||||
"token_request_uri": "https://example.org/token",
|
||||
}
|
||||
}
|
||||
)
|
||||
data = {
|
||||
"database_name": "examples",
|
||||
"masked_encrypted_extra": masked_encrypted_extra,
|
||||
"impersonate_user": True,
|
||||
"sqlalchemy_uri": example_db.safe_sqlalchemy_uri(),
|
||||
"server_cert": None,
|
||||
}
|
||||
url = "api/v1/database/test_connection/"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"superset.commands.database.test_connection.ping",
|
||||
side_effect=Exception("Unauthorized"),
|
||||
),
|
||||
mock.patch.object(
|
||||
example_db.db_engine_spec,
|
||||
"needs_oauth2",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
rv = self.post_assert_metric(url, data, "test_connection")
|
||||
|
||||
assert rv.status_code == 200
|
||||
assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
|
||||
assert json.loads(rv.data.decode("utf-8")) == {"message": "OK"}
|
||||
|
||||
def test_test_connection_failed(self):
|
||||
"""
|
||||
Database API: Test test connection failed
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import Database
|
||||
|
||||
# A Custom SQL ad-hoc metric exactly as Explore serializes it into a
|
||||
# ``/api/v1/chart/data`` payload. Its auto-derived ``label`` is the SQL text
|
||||
# itself, which is what makes the downstream failure mode so confusing.
|
||||
CUSTOM_SQL_METRIC: dict[str, Any] = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "count(DISTINCT product_line)",
|
||||
"label": "count(DISTINCT product_line)",
|
||||
"hasCustomLabel": False,
|
||||
"optionName": "metric_abc123",
|
||||
}
|
||||
|
||||
# The same metric with ``expressionType`` absent. Every other key still marks it
|
||||
# unambiguously as an ad-hoc definition rather than a reference to a metric
|
||||
# saved on the dataset.
|
||||
MALFORMED_ADHOC_METRIC: dict[str, Any] = {
|
||||
key: value for key, value in CUSTOM_SQL_METRIC.items() if key != "expressionType"
|
||||
}
|
||||
|
||||
|
||||
def _chart_data_payload(metric: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"datasource": {"id": 1, "type": "table"},
|
||||
"queries": [
|
||||
{
|
||||
"columns": ["source", "target"],
|
||||
"metrics": [metric],
|
||||
"row_limit": 100,
|
||||
}
|
||||
],
|
||||
"result_format": "json",
|
||||
"result_type": "full",
|
||||
}
|
||||
|
||||
|
||||
def _load_metrics(payload: dict[str, Any]) -> Any:
|
||||
"""Deserialize a chart data payload the way ``/api/v1/chart/data`` does."""
|
||||
from superset.charts.schemas import ChartDataQueryContextSchema
|
||||
|
||||
with patch(
|
||||
"superset.common.query_context_factory.DatasourceDAO.get_datasource",
|
||||
return_value=MagicMock(),
|
||||
):
|
||||
query_context = ChartDataQueryContextSchema().load(payload)
|
||||
return query_context.queries[0].metrics
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(mocker: MockerFixture, session: Session) -> Database:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.core import Database
|
||||
|
||||
SqlaTable.metadata.create_all(session.get_bind())
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
future=True,
|
||||
)
|
||||
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
|
||||
|
||||
connection = engine.raw_connection()
|
||||
connection.execute("CREATE TABLE t (product_line TEXT, source TEXT, target TEXT)")
|
||||
connection.commit()
|
||||
|
||||
# since we're using an in-memory SQLite database, make sure we always
|
||||
# return the same engine where the table was created
|
||||
@contextmanager
|
||||
def mock_get_sqla_engine(catalog=None, schema=None, **kwargs):
|
||||
yield engine
|
||||
|
||||
mocker.patch.object(database, "get_sqla_engine", new=mock_get_sqla_engine)
|
||||
|
||||
return database
|
||||
|
||||
|
||||
def _table(database: Database) -> Any:
|
||||
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
||||
|
||||
return SqlaTable(
|
||||
database=database,
|
||||
schema=None,
|
||||
table_name="t",
|
||||
columns=[
|
||||
TableColumn(column_name="product_line"),
|
||||
TableColumn(column_name="source"),
|
||||
TableColumn(column_name="target"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_adhoc_metric_without_expression_type_is_not_read_as_a_saved_metric(
|
||||
app_context: Any,
|
||||
) -> None:
|
||||
"""
|
||||
An ad-hoc metric that is missing ``expressionType`` must not be silently
|
||||
reinterpreted as a reference to a metric saved on the dataset.
|
||||
|
||||
``QueryObject._set_metrics`` used to rewrite any metric ``dict`` that was
|
||||
not recognized as ad-hoc down to its ``label``, to support the legacy
|
||||
``{"label": "saved_metric_name"}`` reference format. ``is_adhoc_metric``
|
||||
recognizes a metric solely by the presence of ``expressionType``, so an
|
||||
ad-hoc definition that lacks that one key used to be collapsed into a bare
|
||||
string. For a Custom SQL metric the label is the SQL text, so the request
|
||||
was then resolved as if the user had asked for a saved metric literally
|
||||
named ``count(DISTINCT product_line)``.
|
||||
|
||||
``ChartDataAdhocMetricSchema`` declares ``expressionType`` as required, but
|
||||
``ChartDataQueryObjectSchema.metrics`` is a list of ``fields.Raw``, so that
|
||||
contract is never enforced at the API boundary. ``_set_metrics`` is the
|
||||
last point that can tell an ad-hoc-shaped dict apart from a legacy
|
||||
reference, so it must reject the malformed shape outright rather than
|
||||
guess.
|
||||
"""
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
|
||||
with pytest.raises(
|
||||
QueryObjectValidationError,
|
||||
match=r"Invalid ad-hoc metric count\(DISTINCT product_line\): "
|
||||
r"`expressionType` is missing",
|
||||
):
|
||||
_load_metrics(_chart_data_payload(MALFORMED_ADHOC_METRIC))
|
||||
|
||||
|
||||
def test_malformed_adhoc_metric_surfaces_as_a_missing_saved_metric(
|
||||
database: Database,
|
||||
) -> None:
|
||||
"""
|
||||
Downstream symptom of the coercion above: once the ad-hoc definition has
|
||||
been reduced to its label, metric resolution looks the label up among the
|
||||
dataset's saved metrics, fails, and reports the SQL text as a metric name.
|
||||
"""
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
|
||||
with pytest.raises(
|
||||
QueryObjectValidationError,
|
||||
match=r"Metric 'count\(DISTINCT product_line\)' does not exist",
|
||||
):
|
||||
_table(database).get_sqla_query(
|
||||
columns=["source", "target"],
|
||||
metrics=["count(DISTINCT product_line)"],
|
||||
extras={},
|
||||
filter=[],
|
||||
granularity=None,
|
||||
is_timeseries=False,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_label_only_metric_still_resolves_to_a_saved_metric_name(
|
||||
app_context: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Guards the fix from over-correcting: a ``dict`` carrying only ``label`` is
|
||||
the documented legacy way to reference a metric saved on the dataset, and
|
||||
must keep collapsing to that name.
|
||||
"""
|
||||
metrics = _load_metrics(_chart_data_payload({"label": "sum__num"}))
|
||||
|
||||
assert metrics == ["sum__num"]
|
||||
|
||||
|
||||
def test_well_formed_custom_sql_metric_is_preserved(
|
||||
app_context: Any,
|
||||
database: Database,
|
||||
) -> None:
|
||||
"""
|
||||
Guards the fix from over-correcting: with ``expressionType`` present the
|
||||
metric stays an ad-hoc definition and builds SQL without consulting the
|
||||
dataset's saved metrics.
|
||||
"""
|
||||
metrics = _load_metrics(_chart_data_payload(CUSTOM_SQL_METRIC))
|
||||
|
||||
assert metrics == [CUSTOM_SQL_METRIC]
|
||||
assert (
|
||||
_table(database).get_sqla_query(
|
||||
columns=["source", "target"],
|
||||
metrics=metrics,
|
||||
extras={},
|
||||
filter=[],
|
||||
granularity=None,
|
||||
is_timeseries=False,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
@@ -17,6 +17,9 @@
|
||||
|
||||
# pylint: disable=import-outside-toplevel, invalid-name, line-too-long
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
@@ -33,6 +36,8 @@ from superset.sql.parse import Table
|
||||
from superset.superset_typing import OAuth2ClientConfig
|
||||
from superset.utils import json
|
||||
from superset.utils.oauth2 import decode_oauth2_state
|
||||
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
|
||||
from tests.unit_tests.fixtures.common import dttm # noqa: F401
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.db_engine_specs.base import OAuth2State
|
||||
@@ -1068,3 +1073,33 @@ def test_validate_parameters_skips_oauth2_connections_with_masked_encrypted_extr
|
||||
|
||||
assert errors == []
|
||||
conn.execute.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target_type,expected_result",
|
||||
[
|
||||
("Date", "'2019-01-02'"),
|
||||
("DateTime", "'2019-01-02 03:04:05'"),
|
||||
("UnknownType", None),
|
||||
],
|
||||
)
|
||||
def test_convert_dttm(
|
||||
target_type: str,
|
||||
expected_result: str | None,
|
||||
dttm: datetime, # noqa: F811
|
||||
) -> None:
|
||||
"""
|
||||
A Date-typed column must produce a plain ISO date literal ('YYYY-MM-DD').
|
||||
|
||||
Without this, ``SqliteEngineSpec.convert_dttm`` (inherited via
|
||||
``ShillelaghEngineSpec``) returns ``None`` for ``types.Date``, and Superset falls
|
||||
back to a full ``'YYYY-MM-DD HH:MM:SS.ffffff'`` literal. shillelagh's virtual
|
||||
table layer parses that bound value with ``datetime.date.fromisoformat``, which
|
||||
rejects the trailing time-of-day and silently coerces the constraint to ``None``,
|
||||
which the GSheets adapter renders as the SQL literal ``null`` -- an unquoted
|
||||
bareword that Google's Chart API parses as a missing column reference, raising
|
||||
"Invalid query: NO_COLUMN: null".
|
||||
"""
|
||||
from superset.db_engine_specs.gsheets import GSheetsEngineSpec
|
||||
|
||||
assert_convert_dttm(GSheetsEngineSpec, target_type, expected_result, dttm)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from scripts import change_detector
|
||||
|
||||
WORKFLOW_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ ".github/workflows/frontend-bundle-size-nightly.yml"
|
||||
)
|
||||
|
||||
|
||||
def load_workflow() -> dict[str, Any]:
|
||||
return yaml.safe_load(WORKFLOW_PATH.read_text())
|
||||
|
||||
|
||||
def test_scheduled_bundle_size_action_uses_read_only_token() -> None:
|
||||
workflow = load_workflow()
|
||||
job = workflow["jobs"]["refresh-baseline"]
|
||||
steps = {step["name"]: step for step in job["steps"]}
|
||||
|
||||
benchmark_step = steps["Update bundle size baseline"]
|
||||
assert benchmark_step["with"]["github-token"] == "${{ secrets.GITHUB_TOKEN }}"
|
||||
assert workflow["permissions"]["contents"] == "read"
|
||||
effective_permissions = job.get("permissions", workflow["permissions"])
|
||||
assert effective_permissions.get("contents") in {None, "read"}
|
||||
|
||||
|
||||
def test_scheduled_bundle_size_changes_trigger_python_tests() -> None:
|
||||
assert change_detector.detect_changes(
|
||||
[".github/workflows/frontend-bundle-size-nightly.yml"],
|
||||
change_detector.PATTERNS["python"],
|
||||
)
|
||||
Reference in New Issue
Block a user