mirror of
https://github.com/apache/superset.git
synced 2026-07-24 23:56:22 +00:00
Compare commits
13 Commits
fix/coales
...
fix/semant
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a16b2acec | ||
|
|
83ea9a1025 | ||
|
|
e9f61c1e66 | ||
|
|
857a194c66 | ||
|
|
5ffdac1871 | ||
|
|
52552c8278 | ||
|
|
134cae07ae | ||
|
|
f3fa1c7d4f | ||
|
|
99841fb504 | ||
|
|
3a53c4ebc3 | ||
|
|
f7d0ec3a2c | ||
|
|
7eceebce1c | ||
|
|
3bdf134aaa |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1,6 +1,6 @@
|
||||
# Notify all committers of DB migration changes, per SIP-59
|
||||
|
||||
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe
|
||||
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe @rusackas
|
||||
|
||||
# Notify some committers of changes in the components
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.2",
|
||||
"js-yaml": "^5.2.0",
|
||||
"js-yaml": "^5.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"generated": "2026-04-17T17:08:35.132Z",
|
||||
"statistics": {
|
||||
"totalDatabases": 80,
|
||||
"withDocumentation": 80,
|
||||
"withConnectionString": 80,
|
||||
"withDrivers": 28,
|
||||
"totalDatabases": 81,
|
||||
"withDocumentation": 81,
|
||||
"withConnectionString": 81,
|
||||
"withDrivers": 29,
|
||||
"withAuthMethods": 5,
|
||||
"supportsJoins": 74,
|
||||
"supportsSubqueries": 77,
|
||||
"supportsJoins": 75,
|
||||
"supportsSubqueries": 78,
|
||||
"supportsDynamicSchema": 29,
|
||||
"supportsCatalog": 20,
|
||||
"averageScore": 54,
|
||||
"averageScore": 53,
|
||||
"maxScore": 201,
|
||||
"byCategory": {
|
||||
"Cloud - AWS": [
|
||||
@@ -20,6 +20,7 @@
|
||||
],
|
||||
"Query Engines": [
|
||||
"Amazon Athena",
|
||||
"Apache DataFusion",
|
||||
"Apache Drill",
|
||||
"Apache Hive",
|
||||
"Apache Impala",
|
||||
@@ -94,6 +95,7 @@
|
||||
"Vertica"
|
||||
],
|
||||
"Apache Projects": [
|
||||
"Apache DataFusion",
|
||||
"Apache Doris",
|
||||
"Apache Drill",
|
||||
"Apache Druid",
|
||||
@@ -108,6 +110,7 @@
|
||||
"Databricks Interactive Cluster"
|
||||
],
|
||||
"Open Source": [
|
||||
"Apache DataFusion",
|
||||
"Apache Doris",
|
||||
"Apache Drill",
|
||||
"Apache Druid",
|
||||
@@ -9698,6 +9701,97 @@
|
||||
"supports_dynamic_schema": true,
|
||||
"supports_catalog": true,
|
||||
"supports_dynamic_catalog": true
|
||||
},
|
||||
"Apache DataFusion": {
|
||||
"time_grains": {
|
||||
"SECOND": true,
|
||||
"FIVE_SECONDS": true,
|
||||
"THIRTY_SECONDS": true,
|
||||
"MINUTE": true,
|
||||
"FIVE_MINUTES": true,
|
||||
"TEN_MINUTES": true,
|
||||
"FIFTEEN_MINUTES": true,
|
||||
"THIRTY_MINUTES": true,
|
||||
"HALF_HOUR": false,
|
||||
"HOUR": true,
|
||||
"SIX_HOURS": false,
|
||||
"DAY": true,
|
||||
"WEEK": true,
|
||||
"WEEK_STARTING_SUNDAY": false,
|
||||
"WEEK_STARTING_MONDAY": false,
|
||||
"WEEK_ENDING_SATURDAY": false,
|
||||
"WEEK_ENDING_SUNDAY": false,
|
||||
"MONTH": true,
|
||||
"QUARTER": true,
|
||||
"QUARTER_YEAR": false,
|
||||
"YEAR": true
|
||||
},
|
||||
"module": "superset.db_engine_specs.datafusion",
|
||||
"limit_method": 1,
|
||||
"limit_clause": true,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"alias_in_select": true,
|
||||
"alias_in_orderby": true,
|
||||
"time_groupby_inline": false,
|
||||
"alias_to_source_column": false,
|
||||
"order_by_not_in_select": true,
|
||||
"expressions_in_orderby": false,
|
||||
"cte_in_subquery": true,
|
||||
"max_column_name": null,
|
||||
"sql_comments": true,
|
||||
"escaped_colons": true,
|
||||
"masked_encrypted_extra": false,
|
||||
"column_type_mapping": false,
|
||||
"function_names": false,
|
||||
"user_impersonation": false,
|
||||
"file_upload": false,
|
||||
"get_extra_table_metadata": false,
|
||||
"dbapi_exception_mapping": false,
|
||||
"custom_errors": false,
|
||||
"dynamic_schema": false,
|
||||
"catalog": false,
|
||||
"dynamic_catalog": false,
|
||||
"ssh_tunneling": true,
|
||||
"query_cancelation": false,
|
||||
"get_metrics": false,
|
||||
"where_latest_partition": false,
|
||||
"expand_data": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false,
|
||||
"score": 24,
|
||||
"max_score": 201,
|
||||
"documentation": {
|
||||
"description": "DataFusion is a highly performant query engine",
|
||||
"logo": "datafusion.png",
|
||||
"homepage_url": "https://datafusion.apache.org/",
|
||||
"categories": [
|
||||
"Query Engines",
|
||||
"Open Source",
|
||||
"Apache Projects"
|
||||
],
|
||||
"pypi_packages": [
|
||||
"flightsql-dbapi"
|
||||
],
|
||||
"connection_string": "datafusion://host:port",
|
||||
"drivers": [
|
||||
{
|
||||
"name": "Arrow Flight SQL (Recommended)",
|
||||
"pypi_package": "flightsql-dbapi",
|
||||
"connection_string": "datafusion://host:port",
|
||||
"is_recommended": true
|
||||
}
|
||||
],
|
||||
"category": "Apache Projects"
|
||||
},
|
||||
"engine": "datafusion",
|
||||
"engine_name": "Apache DataFusion",
|
||||
"engine_aliases": [],
|
||||
"default_driver": "flightsql",
|
||||
"supports_file_upload": false,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
docs/static/img/databases/datafusion.png
vendored
Normal file
BIN
docs/static/img/databases/datafusion.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -10155,10 +10155,10 @@ js-yaml@=4.2.0:
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@^5.2.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.1.tgz#bb3f2e87295ebfe6ef0a75e17c62834daeff9ce2"
|
||||
integrity sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==
|
||||
js-yaml@^5.2.2:
|
||||
version "5.2.2"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.2.tgz#497bfe63f0b0db11c7bbc5ce8bc568e836c8b08c"
|
||||
integrity sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
@@ -11681,10 +11681,10 @@ mz@^2.7.0:
|
||||
object-assign "^4.0.1"
|
||||
thenify-all "^1.0.0"
|
||||
|
||||
nanoid@^3.3.12:
|
||||
version "3.3.15"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.15.tgz#36c490fad8c6e86c824c940dfdde999b69ed4316"
|
||||
integrity sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==
|
||||
nanoid@^3.3.16:
|
||||
version "3.3.16"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
|
||||
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
@@ -12974,11 +12974,11 @@ postcss-zindex@^6.0.2:
|
||||
integrity sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==
|
||||
|
||||
postcss@^8.4.21, postcss@^8.4.24, postcss@^8.4.33, postcss@^8.5.4:
|
||||
version "8.5.16"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.16.tgz#1230ce0b5df354c24c0ea45f99ce5f6a88279d28"
|
||||
integrity sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==
|
||||
version "8.5.23"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594"
|
||||
integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==
|
||||
dependencies:
|
||||
nanoid "^3.3.12"
|
||||
nanoid "^3.3.16"
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ dependencies = [
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy>=1.4.43, <2", # 1.4.43 adds the python-oracledb (oracle+oracledb) dialect
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.12.0, <31",
|
||||
@@ -142,6 +142,7 @@ databricks = [
|
||||
"databricks-sql-connector>=4.2.6, <4.4.0",
|
||||
"databricks-sqlalchemy==1.0.5",
|
||||
]
|
||||
datafusion = ["flightsql-dbapi>=0.2.0, <0.3"]
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
|
||||
@@ -97,7 +97,7 @@ export const databasesPage = {
|
||||
infoAlert: '.ant-alert',
|
||||
serviceAccountInput: '[name="credentials_info"]',
|
||||
connectionStep: {
|
||||
modal: '.ant-modal-content',
|
||||
modal: '.ant-modal-container',
|
||||
modalBody: '.ant-modal-body',
|
||||
stepTitle: '.css-7x6kk > h4',
|
||||
helperBottom: '.helper-bottom',
|
||||
@@ -261,7 +261,7 @@ export const datasetsList = {
|
||||
},
|
||||
},
|
||||
deleteDatasetModal: {
|
||||
modal: '.ant-modal-content',
|
||||
modal: '.ant-modal-container',
|
||||
deleteInput: dataTestLocator('delete-modal-input'),
|
||||
deleteButton: dataTestLocator('modal-confirm-button'),
|
||||
text: '.css-kxmt87',
|
||||
@@ -474,7 +474,7 @@ export const exploreView = {
|
||||
},
|
||||
chartAreaItem: '.nv-legend-text',
|
||||
viewQueryModal: {
|
||||
container: '.ant-modal-content',
|
||||
container: '.ant-modal-container',
|
||||
closeButton: 'button.ant-modal-close',
|
||||
},
|
||||
embedCodeModal: {
|
||||
@@ -482,7 +482,7 @@ export const exploreView = {
|
||||
textfield: dataTestLocator('embed-code-textarea'),
|
||||
},
|
||||
saveModal: {
|
||||
modal: '.ant-modal-content',
|
||||
modal: '.ant-modal-container',
|
||||
chartNameInput: dataTestLocator('new-chart-name'),
|
||||
dashboardNameInput: '.ant-select-input',
|
||||
addToDashboardInput: dataTestLocator(
|
||||
@@ -578,7 +578,7 @@ export const exploreView = {
|
||||
},
|
||||
},
|
||||
editDatasetModal: {
|
||||
container: '.ant-modal-content',
|
||||
container: '.ant-modal-container',
|
||||
datasetTabsContainer: dataTestLocator('edit-dataset-tabs'),
|
||||
saveButton: dataTestLocator('datasource-modal-save'),
|
||||
metricsTab: {
|
||||
@@ -617,12 +617,12 @@ export const dashboardView = {
|
||||
closeButton: dataTestLocator('close-button'),
|
||||
},
|
||||
saveModal: {
|
||||
modal: '.ant-modal-content',
|
||||
modal: '.ant-modal-container',
|
||||
dashboardNameInput: '.ant-input',
|
||||
saveButton: dataTestLocator('modal-save-dashboard-button'),
|
||||
},
|
||||
dashboardProperties: {
|
||||
modal: '.ant-modal-content',
|
||||
modal: '.ant-modal-container',
|
||||
dashboardTitleInput: dataTestLocator('dashboard-title-input'),
|
||||
modalButton: '[type="button"]',
|
||||
},
|
||||
|
||||
16
superset-frontend/package-lock.json
generated
16
superset-frontend/package-lock.json
generated
@@ -33382,9 +33382,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -33402,7 +33402,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -33552,9 +33552,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -44648,7 +44648,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "^9.3.7",
|
||||
"@deck.gl/mapbox": "~9.3.7",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
|
||||
@@ -84,7 +84,7 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
`
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
.ant-modal-container {
|
||||
background-color: ${theme.colorBgContainer};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -196,7 +196,7 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
.ant-modal-container {
|
||||
height: 100%;
|
||||
|
||||
.ant-modal-body {
|
||||
|
||||
@@ -60,18 +60,26 @@ test('should render with success icon', () => {
|
||||
expect(screen.getByLabelText('check-circle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The stripes are painted on antd's inner progress track, so assert the
|
||||
// computed style of that element — asserting the outer container would pass
|
||||
// whether or not the gradient ever reached the track.
|
||||
const getTrack = (container: HTMLElement) =>
|
||||
container.querySelector('.ant-progress-track') as HTMLElement;
|
||||
|
||||
test('should render with stripes', () => {
|
||||
const stripedProps = {
|
||||
...mockedProps,
|
||||
striped: true,
|
||||
};
|
||||
const { container } = render(<ProgressBar {...stripedProps} />);
|
||||
expect(container).toHaveStyle(
|
||||
`background-image: 'linear-gradient(
|
||||
45deg,rgba(255, 255, 255, 0.15) 25%,
|
||||
transparent 25%, transparent 50%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
rgba(255, 255, 255, 0.15) 75%,
|
||||
transparent 75%, transparent) !important'`,
|
||||
expect(getComputedStyle(getTrack(container)).backgroundImage).toContain(
|
||||
'linear-gradient(45deg',
|
||||
);
|
||||
});
|
||||
|
||||
test('should render without stripes by default', () => {
|
||||
const { container } = render(<ProgressBar {...mockedProps} />);
|
||||
expect(getComputedStyle(getTrack(container)).backgroundImage).not.toContain(
|
||||
'linear-gradient',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,10 +29,10 @@ const ProgressBar = styled(({ striped, ...props }: ProgressBarProps) => (
|
||||
<AntdProgress data-test="progress-bar" {...props} />
|
||||
))`
|
||||
position: static;
|
||||
.ant-progress-inner {
|
||||
.ant-progress-rail {
|
||||
position: static;
|
||||
}
|
||||
.ant-progress-bg {
|
||||
.ant-progress-track {
|
||||
position: static;
|
||||
${({ striped }) =>
|
||||
striped &&
|
||||
|
||||
@@ -857,7 +857,7 @@ const AsyncSelect = forwardRef(
|
||||
getPopupContainer={
|
||||
getPopupContainer ||
|
||||
((triggerNode: HTMLElement) =>
|
||||
(triggerNode?.closest('.ant-modal-content') as HTMLElement) ||
|
||||
(triggerNode?.closest('.ant-modal-container') as HTMLElement) ||
|
||||
(triggerNode.parentNode as HTMLElement))
|
||||
}
|
||||
headerPosition={headerPosition}
|
||||
|
||||
@@ -912,7 +912,7 @@ const Select = forwardRef(
|
||||
getPopupContainer={
|
||||
getPopupContainer ||
|
||||
((triggerNode: HTMLElement) =>
|
||||
(triggerNode?.closest('.ant-modal-content') as HTMLElement) ||
|
||||
(triggerNode?.closest('.ant-modal-container') as HTMLElement) ||
|
||||
(triggerNode.parentNode as HTMLElement))
|
||||
}
|
||||
headerPosition={headerPosition}
|
||||
|
||||
@@ -61,7 +61,7 @@ const StyledTable = styled(AntTable)(
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading .ant-spin .ant-spin-dot {
|
||||
.ant-spin .ant-spin-dot {
|
||||
width: ${theme.sizeUnit * 12}px;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
|
||||
scrollbar-color: ${theme.colorFillSecondary} ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading .ant-spin .ant-spin-dot {
|
||||
.ant-spin .ant-spin-dot {
|
||||
width: ${theme.sizeXXL}px;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
@@ -31,10 +31,22 @@
|
||||
* the exact class names our CSS depends on, so a future antd bump that renames
|
||||
* one fails here with a clear pointer instead of shipping a visual regression.
|
||||
*/
|
||||
import { render } from '@superset-ui/core/spec';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Collapse, Modal, Popover, Steps, Tabs, Tag, Tooltip } from 'antd';
|
||||
import { Select } from './Select';
|
||||
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
|
||||
import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
Alert,
|
||||
Collapse,
|
||||
Modal,
|
||||
Popover,
|
||||
Progress,
|
||||
Spin,
|
||||
Steps,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Modal as SupersetModal } from './Modal';
|
||||
import { AsyncSelect, Select } from './Select';
|
||||
|
||||
const antClasses = (root: ParentNode): string[] => {
|
||||
const classes = new Set<string>();
|
||||
@@ -176,9 +188,17 @@ test('Steps classes (QueryStatusBar, ChartCreation, SQL Lab loading detection)',
|
||||
'ant-steps-item-icon',
|
||||
'ant-steps-item-title',
|
||||
'ant-steps-item-rail',
|
||||
'ant-steps-item-content',
|
||||
]),
|
||||
);
|
||||
expect(classes).not.toContain('ant-steps-item-tail');
|
||||
// antd 6 removed the nested `.ant-steps-item-description`; the description text
|
||||
// now renders directly inside `.ant-steps-item-content`. ChartCreation styles
|
||||
// the description by targeting `.ant-steps-item-content` for exactly this reason.
|
||||
expect(classes).not.toContain('ant-steps-item-description');
|
||||
expect(container.querySelector('.ant-steps-item-content')).toHaveTextContent(
|
||||
'd',
|
||||
);
|
||||
});
|
||||
|
||||
test('Select suffix (arrow) class (plugin-chart-table page-size Select targets it)', () => {
|
||||
@@ -216,13 +236,126 @@ test('Collapse panel/body classes (Collapse.tsx, VizTypeGallery, config modals)'
|
||||
expect(classes).not.toContain('ant-collapse-content-box');
|
||||
});
|
||||
|
||||
test('Modal body class (many *.styles.ts modal overrides target it)', () => {
|
||||
test('Modal container + body classes (many *.styles.ts modal overrides target them)', () => {
|
||||
render(
|
||||
<Modal open title="t">
|
||||
body
|
||||
</Modal>,
|
||||
);
|
||||
expect(antClasses(document.body)).toContain('ant-modal-body');
|
||||
const classes = antClasses(document.body);
|
||||
// antd 6 renamed the modal content wrapper `.ant-modal-content` ->
|
||||
// `.ant-modal-container`. Several `.styles.ts` overrides target it, and Select /
|
||||
// popup `getPopupContainer`/`.closest()` lookups anchor popups to it so their
|
||||
// menus stay clipped inside the modal. `-body`/`-close` are unchanged but the
|
||||
// report-screenshot error-modal flow in `webdriver.py` walks all three, so pin
|
||||
// the whole chain it depends on.
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ant-modal-container',
|
||||
'ant-modal-body',
|
||||
'ant-modal-close',
|
||||
]),
|
||||
);
|
||||
expect(classes).not.toContain('ant-modal-content');
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
'Select',
|
||||
<Select
|
||||
ariaLabel="in-modal"
|
||||
options={[{ label: 'Alpha', value: 'a' }]}
|
||||
key="sync"
|
||||
/>,
|
||||
],
|
||||
[
|
||||
'AsyncSelect',
|
||||
<AsyncSelect
|
||||
ariaLabel="in-modal"
|
||||
options={async () => ({
|
||||
data: [{ label: 'Alpha', value: 'a' }],
|
||||
totalCount: 1,
|
||||
})}
|
||||
key="async"
|
||||
/>,
|
||||
],
|
||||
])(
|
||||
'%s popup mounts inside the enclosing modal container',
|
||||
async (_name, select) => {
|
||||
// Select/AsyncSelect anchor their popup with
|
||||
// `triggerNode.closest('.ant-modal-container')` so the menu is clipped by
|
||||
// the modal instead of escaping to <body> and scrolling away from its
|
||||
// trigger. The class contract above only proves the class exists — this
|
||||
// asserts the containment behaviour it exists for.
|
||||
render(
|
||||
<SupersetModal show title="t" onHide={() => {}}>
|
||||
{select}
|
||||
</SupersetModal>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await screen.findByTitle('Alpha');
|
||||
|
||||
await waitFor(() => {
|
||||
const popup = document.querySelector('.ant-select-dropdown');
|
||||
expect(popup).not.toBeNull();
|
||||
// Assert the *direct* parent, not just an ancestor: when the `.closest()`
|
||||
// lookup misses, the `triggerNode.parentNode` fallback still sits inside
|
||||
// the modal, so an `ancestor` check passes either way and proves nothing.
|
||||
expect(popup?.parentElement).toHaveClass('ant-modal-container');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('Progress rail/track classes (ProgressBar + DatabaseModal striped overrides target them)', () => {
|
||||
const { container } = render(<Progress percent={50} />);
|
||||
const classes = antClasses(container);
|
||||
// antd 6 renamed `.ant-progress-inner` -> `.ant-progress-rail` and
|
||||
// `.ant-progress-bg` -> `.ant-progress-track`. ProgressBar's styled wrapper
|
||||
// paints the striped gradient on the track; DatabaseModal sizes the rail.
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining(['ant-progress-rail', 'ant-progress-track']),
|
||||
);
|
||||
expect(classes).not.toContain('ant-progress-inner');
|
||||
expect(classes).not.toContain('ant-progress-bg');
|
||||
});
|
||||
|
||||
test('Alert title/actions classes (ImportModal, DatabaseModal, SqlEditor, native-filter overrides target them)', () => {
|
||||
const { container } = render(
|
||||
<Alert
|
||||
type="info"
|
||||
title="msg"
|
||||
description="desc"
|
||||
action={<span>x</span>}
|
||||
/>,
|
||||
);
|
||||
const classes = antClasses(container);
|
||||
// antd 6 renamed `.ant-alert-message` -> `.ant-alert-title` and pluralised the
|
||||
// action slot `.ant-alert-action` -> `.ant-alert-actions`. Several `.styles.ts`
|
||||
// overrides and the SQL Lab / native-filter modal footers target these.
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining(['ant-alert-title', 'ant-alert-actions']),
|
||||
);
|
||||
expect(classes).not.toContain('ant-alert-message');
|
||||
expect(classes).not.toContain('ant-alert-action');
|
||||
});
|
||||
|
||||
test('Spin nested structure (Table/VirtualTable spinner sizing overrides target it)', () => {
|
||||
const { container } = render(
|
||||
<Spin spinning>
|
||||
<div>content</div>
|
||||
</Spin>,
|
||||
);
|
||||
const classes = antClasses(container);
|
||||
// antd 6 dropped the `.ant-spin-nested-loading` wrapper: `.ant-spin` is now the
|
||||
// outer element and `.ant-spin-dot` lives beneath it. Table/VirtualTable size the
|
||||
// dot via `.ant-spin .ant-spin-dot`, so that descendant chain must hold.
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining(['ant-spin', 'ant-spin-container', 'ant-spin-dot']),
|
||||
);
|
||||
expect(classes).not.toContain('ant-spin-nested-loading');
|
||||
expect(
|
||||
container.querySelector('.ant-spin-dot')?.closest('.ant-spin'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('Tag keeps its v5 trailing margin (GlobalStyles parity rule)', () => {
|
||||
|
||||
@@ -63,6 +63,26 @@ export class Menu {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a top-level menu item by its accessible name.
|
||||
*
|
||||
* Ant Design menu items render as `div[role="menuitem"]` labelled by their
|
||||
* text, so the accessible name is the stable handle — most items carry no
|
||||
* `data-test`. Use this for flat items; use {@link selectSubmenuItem} for
|
||||
* items nested under a submenu.
|
||||
*
|
||||
* @param itemText - The accessible name of the item (e.g., "Refresh dashboard")
|
||||
* @param options - Optional timeout settings
|
||||
*/
|
||||
async selectItem(
|
||||
itemText: string,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await this.locator
|
||||
.getByRole('menuitem', { name: itemText, exact: true })
|
||||
.click({ timeout: options?.timeout ?? TIMEOUT.FORM_LOAD });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a submenu and selects an item within it.
|
||||
* Uses hover as primary approach, falls back to keyboard then dispatchEvent.
|
||||
|
||||
@@ -506,7 +506,7 @@ test('save flow and first dashboard screenshots', async ({ page }) => {
|
||||
await expect(saveButton).toBeVisible({ timeout: 10000 });
|
||||
await saveButton.click();
|
||||
|
||||
const modal = page.locator('.ant-modal-content').filter({
|
||||
const modal = page.locator('.ant-modal-container').filter({
|
||||
has: page.locator('[data-test="save-modal-body"]'),
|
||||
});
|
||||
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -45,7 +45,7 @@ images:
|
||||
alt: ""
|
||||
source_file: docs/using-superset/creating-your-first-dashboard.mdx
|
||||
app_path: null
|
||||
selector: .ant-modal-content
|
||||
selector: .ant-modal-container
|
||||
- type: tutorial
|
||||
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
|
||||
image_url: "https://superset.apache.org/img/tutorial/tutorial_03a_database_connection_string_link.png"
|
||||
@@ -53,7 +53,7 @@ images:
|
||||
alt: ""
|
||||
source_file: docs/using-superset/creating-your-first-dashboard.mdx
|
||||
app_path: null
|
||||
selector: .ant-modal-content
|
||||
selector: .ant-modal-container
|
||||
- type: tutorial
|
||||
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
|
||||
image_url: "https://superset.apache.org/img/tutorial/tutorial_03b_connection_string_details.png"
|
||||
@@ -61,7 +61,7 @@ images:
|
||||
alt: ""
|
||||
source_file: docs/using-superset/creating-your-first-dashboard.mdx
|
||||
app_path: null
|
||||
selector: .ant-modal-content
|
||||
selector: .ant-modal-container
|
||||
- type: tutorial
|
||||
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
|
||||
image_url: "https://superset.apache.org/img/tutorial/tutorial_09_add_new_table.png"
|
||||
@@ -69,7 +69,7 @@ images:
|
||||
alt: ""
|
||||
source_file: docs/using-superset/creating-your-first-dashboard.mdx
|
||||
app_path: null
|
||||
selector: .ant-modal-content
|
||||
selector: .ant-modal-container
|
||||
- type: tutorial
|
||||
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
|
||||
image_url: "https://superset.apache.org/img/tutorial/tutorial_calculated_column.png"
|
||||
@@ -93,7 +93,7 @@ images:
|
||||
alt: ""
|
||||
source_file: docs/using-superset/creating-your-first-dashboard.mdx
|
||||
app_path: null
|
||||
selector: .ant-modal-content
|
||||
selector: .ant-modal-container
|
||||
- type: tutorial
|
||||
page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard"
|
||||
image_url: "https://superset.apache.org/img/tutorial/tutorial_explore_run.jpg"
|
||||
@@ -141,7 +141,7 @@ images:
|
||||
alt: ""
|
||||
source_file: docs/using-superset/exploring-data.mdx
|
||||
app_path: null
|
||||
selector: .ant-modal-content
|
||||
selector: .ant-modal-container
|
||||
- type: tutorial
|
||||
page_url: "https://superset.apache.org/user-docs/using-superset/exploring-data"
|
||||
image_url: "https://superset.apache.org/img/tutorial/annotation.png"
|
||||
@@ -197,7 +197,7 @@ images:
|
||||
alt: ""
|
||||
source_file: docs/using-superset/exploring-data.mdx
|
||||
app_path: null
|
||||
selector: .ant-modal-content
|
||||
selector: .ant-modal-container
|
||||
- type: tutorial
|
||||
page_url: "https://superset.apache.org/user-docs/using-superset/exploring-data"
|
||||
image_url: "https://superset.apache.org/img/tutorial/markdown.png"
|
||||
@@ -261,7 +261,7 @@ images:
|
||||
alt: ""
|
||||
source_file: docs/using-superset/exploring-data.mdx
|
||||
app_path: null
|
||||
selector: .ant-modal-content
|
||||
selector: .ant-modal-container
|
||||
- type: tutorial
|
||||
page_url: "https://superset.apache.org/user-docs/using-superset/exploring-data"
|
||||
image_url: "https://superset.apache.org/img/tutorial/select_dates_pivot_table.png"
|
||||
|
||||
@@ -167,6 +167,23 @@ export class DashboardPage {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard header actions dropdown menu. Call after
|
||||
* {@link openHeaderActionsMenu}, which is what makes the menu visible.
|
||||
*/
|
||||
private headerActionsMenu(): Menu {
|
||||
return new Menu(this.page, DashboardPage.SELECTORS.HEADER_ACTIONS_MENU);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a dashboard-level force refresh via the header actions menu.
|
||||
* Re-runs every chart's query with `force=true`, bypassing the cache.
|
||||
*/
|
||||
async forceRefresh(): Promise<void> {
|
||||
await this.openHeaderActionsMenu();
|
||||
await this.headerActionsMenu().selectItem('Refresh dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects an option from the Download submenu.
|
||||
* Opens the header actions menu, navigates to Download submenu,
|
||||
@@ -177,10 +194,7 @@ export class DashboardPage {
|
||||
async selectDownloadOption(optionText: string): Promise<Download> {
|
||||
await this.openHeaderActionsMenu();
|
||||
|
||||
const menu = new Menu(
|
||||
this.page,
|
||||
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
|
||||
);
|
||||
const menu = this.headerActionsMenu();
|
||||
const downloadPromise = this.page.waitForEvent('download');
|
||||
await menu.selectSubmenuItem('Download', optionText);
|
||||
return downloadPromise;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E migration of the Cypress "Dashboard top-level controls" suite
|
||||
* (dashboard/controls.test.ts).
|
||||
*
|
||||
* The genuine end-to-end behaviour is the dashboard-level force refresh: every
|
||||
* chart on the dashboard must re-query the backend with `force=true`. This can
|
||||
* only be verified against a real backend, so it is migrated here.
|
||||
*
|
||||
* Scope note on cache bypass: the original Cypress test ran against the
|
||||
* pre-seeded World Health dashboard and asserted `is_cached === false` on the
|
||||
* refreshed responses, proving force-refresh busted a populated cache. This
|
||||
* migration asserts the observable network contract instead — every chart
|
||||
* re-queries with `force=true` and each response is accepted (200) — and does
|
||||
* not assert `is_cached`. Tying the assertion to cache state would mean
|
||||
* reasoning about whatever the initial dashboard load left in the DATA_CACHE and
|
||||
* racing its 30s TTL, reintroducing the flakiness this migration exists to
|
||||
* remove. `force=true` is itself the server-side instruction to bypass the
|
||||
* cache, so asserting it fires on every distinct chart — and each request is
|
||||
* accepted (200) — is a direct, deterministic check of the force-refresh
|
||||
* behaviour.
|
||||
*
|
||||
* The other case ("should allow chart level refresh") only asserted the menu
|
||||
* item's `ant-dropdown-menu-item-disabled` class — a DOM/state assertion with no
|
||||
* backend round-trip, and one the original itself flagged as flaky. It belongs
|
||||
* in component/RTL coverage and is intentionally not migrated.
|
||||
*
|
||||
* CI green => force refresh re-queries every distinct chart with force=true and
|
||||
* each request returns 200.
|
||||
* CI red => force refresh did not re-query every chart, or a request failed.
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import {
|
||||
createDashboardWithCharts,
|
||||
sliceIdFromChartDataUrl,
|
||||
} from './dashboard-test-helpers';
|
||||
|
||||
testWithAssets(
|
||||
'dashboard-level force refresh re-queries every chart with force=true',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const { dashboardId, charts } = await createDashboardWithCharts(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
datasetName: 'birth_names',
|
||||
chartNamePrefix: 'controls',
|
||||
dashboardTitlePrefix: 'controls_force_refresh',
|
||||
chartSpecs: [
|
||||
{ viz_type: 'big_number_total', params: { metric: 'count' } },
|
||||
{
|
||||
viz_type: 'table',
|
||||
params: {
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['name'],
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const chartIds = charts.map(chart => chart.id);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard.waitForChartsToLoad();
|
||||
|
||||
// Capture the force-refresh chart-data round-trips, keyed by the chart each
|
||||
// one queried for. A `page.on('response')` listener rather than the
|
||||
// `waitForPost` helper: those resolve on a single response, and this needs
|
||||
// to collect every chart's request and correlate them by slice id. Set up
|
||||
// after the initial load so only the forced requests are recorded. Statuses
|
||||
// accumulate per chart rather than being overwritten, so a failure followed
|
||||
// by a successful retry still surfaces, provided both responses land before
|
||||
// the assertions read the map. That is a defence-in-depth choice more than a
|
||||
// guarantee: the client retries only 502/503/504, so an application-level
|
||||
// failure is never retried away to begin with.
|
||||
const forcedStatusesBySliceId = new Map<number, number[]>();
|
||||
page.on('response', response => {
|
||||
const request = response.request();
|
||||
if (request.method() !== 'POST') {
|
||||
return;
|
||||
}
|
||||
const url = new URL(response.url());
|
||||
// Match `force` exactly as a query param — a substring check would also
|
||||
// accept `enforce=true` or `force=trueish`.
|
||||
if (
|
||||
!url.pathname.includes('/api/v1/chart/data') ||
|
||||
url.searchParams.get('force') !== 'true'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sliceId = sliceIdFromChartDataUrl(response.url());
|
||||
if (sliceId !== undefined) {
|
||||
const statuses = forcedStatusesBySliceId.get(sliceId) ?? [];
|
||||
statuses.push(response.status());
|
||||
forcedStatusesBySliceId.set(sliceId, statuses);
|
||||
}
|
||||
});
|
||||
|
||||
await dashboard.forceRefresh();
|
||||
|
||||
// Every chart — identified by slice_id, not merely by request count — must
|
||||
// fire its own forced re-query, and no foreign chart may: polling the
|
||||
// distinct set (rather than the raw request count) rejects a regression that
|
||||
// refreshes one chart twice while skipping another. On timeout the diff
|
||||
// names the slice ids that never arrived.
|
||||
const byId = (a: number, b: number) => a - b;
|
||||
await expect
|
||||
.poll(() => [...forcedStatusesBySliceId.keys()].sort(byId), {
|
||||
message:
|
||||
'every dashboard chart should issue its own forced /api/v1/chart/data request',
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
})
|
||||
.toEqual([...chartIds].sort(byId));
|
||||
|
||||
// Every forced request recorded for a chart was accepted — all attempts, not
|
||||
// just the last. The poll above already established that the recorded slice
|
||||
// ids are exactly the dashboard's charts, so iterating the map needs no
|
||||
// presence or emptiness guard.
|
||||
for (const [sliceId, statuses] of forcedStatusesBySliceId) {
|
||||
expect(
|
||||
statuses.every(status => status === 200),
|
||||
`chart ${sliceId}'s forced /api/v1/chart/data responses should all be 200, got [${statuses}]`,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -35,18 +35,14 @@
|
||||
*/
|
||||
import type { Locator } from '@playwright/test';
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import {
|
||||
apiPostDashboard,
|
||||
buildSingleRowDashboardLayout,
|
||||
type DashboardLayoutChart,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import { type DashboardLayoutChart } from '../../helpers/api/dashboard';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import {
|
||||
createDashboardWithCharts,
|
||||
sliceIdFromChartDataUrl,
|
||||
} from './dashboard-test-helpers';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const ECHARTS_SERIES_COLOR: [number, number, number] = [31, 168, 201];
|
||||
|
||||
type ChartOutput = 'big-number' | 'table' | 'echarts';
|
||||
@@ -85,22 +81,22 @@ async function expectChartOutput(
|
||||
const value = chart.locator(
|
||||
'.superset-legacy-chart-big-number .header-line',
|
||||
);
|
||||
await expect(value).toBeVisible();
|
||||
await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
|
||||
await expect(value).toHaveText(/\d/);
|
||||
return;
|
||||
}
|
||||
if (output === 'table') {
|
||||
await expect(
|
||||
chart.locator('table tbody tr:not(:has(.dt-no-results))').first(),
|
||||
).toBeVisible();
|
||||
).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = chart.locator('canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await expect(canvas).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
|
||||
await expect
|
||||
.poll(() => canvasColorPixelCount(canvas, ECHARTS_SERIES_COLOR), {
|
||||
timeout: TIMEOUT.API_RESPONSE * 2,
|
||||
timeout: TIMEOUT.CHART_RENDER,
|
||||
message: 'ECharts canvas should paint the configured data series',
|
||||
})
|
||||
.toBeGreaterThan(20);
|
||||
@@ -109,17 +105,14 @@ async function expectChartOutput(
|
||||
testWithAssets(
|
||||
'dashboard loads and every chart renders via real queries',
|
||||
async ({ page, testAssets }) => {
|
||||
// Building + loading a multi-chart dashboard chains several slow queries.
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
// Building + loading a multi-chart dashboard chains several slow queries:
|
||||
// API setup per chart, then an uncached render for each. A loaded runner has
|
||||
// been seen spending ~45s on that chain, which leaves the standard slow
|
||||
// budget no room to actually spend TIMEOUT.CHART_RENDER.
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST * 2);
|
||||
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
const datasource = `${datasetId}__table`;
|
||||
|
||||
// A spread of viz types that all render cleanly from the birth_names dataset.
|
||||
// A spread of viz types that all render cleanly from the birth_names dataset,
|
||||
// each paired with the terminal output it should paint.
|
||||
const chartSpecs: {
|
||||
viz_type: string;
|
||||
output: ChartOutput;
|
||||
@@ -128,14 +121,12 @@ testWithAssets(
|
||||
{
|
||||
viz_type: 'big_number_total',
|
||||
output: 'big-number',
|
||||
params: { datasource, viz_type: 'big_number_total', metric: 'count' },
|
||||
params: { metric: 'count' },
|
||||
},
|
||||
{
|
||||
viz_type: 'table',
|
||||
output: 'table',
|
||||
params: {
|
||||
datasource,
|
||||
viz_type: 'table',
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['name'],
|
||||
metrics: ['count'],
|
||||
@@ -146,8 +137,6 @@ testWithAssets(
|
||||
viz_type: 'echarts_timeseries_line',
|
||||
output: 'echarts',
|
||||
params: {
|
||||
datasource,
|
||||
viz_type: 'echarts_timeseries_line',
|
||||
x_axis: 'ds',
|
||||
xAxisForceCategorical: true,
|
||||
time_grain_sqla: 'P1Y',
|
||||
@@ -160,49 +149,23 @@ testWithAssets(
|
||||
},
|
||||
];
|
||||
|
||||
// Parallel-safe suffix so chart/dashboard names never collide across workers.
|
||||
const uniqueSuffix = `${Date.now()}_${testWithAssets.info().parallelIndex}`;
|
||||
|
||||
// Create each chart via the API.
|
||||
const charts: CreatedChart[] = [];
|
||||
for (const spec of chartSpecs) {
|
||||
const sliceName = `load_smoke_${spec.viz_type}_${uniqueSuffix}`;
|
||||
const resp = await apiPostChart(page, {
|
||||
slice_name: sliceName,
|
||||
viz_type: spec.viz_type,
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(spec.params),
|
||||
const { dashboardId, charts: createdCharts } =
|
||||
await createDashboardWithCharts(page, testAssets, testWithAssets.info(), {
|
||||
datasetName: 'birth_names',
|
||||
chartNamePrefix: 'load_smoke',
|
||||
dashboardTitlePrefix: 'load_smoke',
|
||||
chartSpecs,
|
||||
});
|
||||
expect(resp.ok()).toBe(true);
|
||||
const chartId = await extractIdFromResponse(resp);
|
||||
testAssets.trackChart(chartId);
|
||||
charts.push({ id: chartId, sliceName, output: spec.output });
|
||||
}
|
||||
// Pair each created chart back to the output it should paint (same order).
|
||||
const charts: CreatedChart[] = createdCharts.map((chart, index) => ({
|
||||
...chart,
|
||||
output: chartSpecs[index].output,
|
||||
}));
|
||||
const chartIds = charts.map(chart => chart.id);
|
||||
|
||||
// Lay all charts out in a single row.
|
||||
const positionJson = buildSingleRowDashboardLayout(charts);
|
||||
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `load_smoke_${uniqueSuffix}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
// Associate every chart with the dashboard so they actually render.
|
||||
for (const chartId of chartIds) {
|
||||
await apiPutChart(page, chartId, { dashboards: [dashboardId] });
|
||||
}
|
||||
|
||||
// Record the real chart-data round-trips the dashboard makes on load,
|
||||
// keyed by the chart each one queried for. The chart-data POST carries its
|
||||
// slice id in the encoded `form_data={"slice_id":<id>}` query param (see
|
||||
// chartAction.ts), so parsing it lets us prove every chart queried — not
|
||||
// just that some chart did.
|
||||
// keyed by the chart each one queried for, so we can prove every chart
|
||||
// queried — not just that some chart did.
|
||||
const chartDataStatusBySliceId = new Map<number, number>();
|
||||
page.on('response', response => {
|
||||
const request = response.request();
|
||||
@@ -212,17 +175,9 @@ testWithAssets(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const formData = new URL(response.url()).searchParams.get('form_data');
|
||||
if (!formData) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sliceId = JSON.parse(formData).slice_id;
|
||||
if (typeof sliceId === 'number') {
|
||||
chartDataStatusBySliceId.set(sliceId, response.status());
|
||||
}
|
||||
} catch {
|
||||
// Not a slice-id form_data payload; ignore.
|
||||
const sliceId = sliceIdFromChartDataUrl(response.url());
|
||||
if (sliceId !== undefined) {
|
||||
chartDataStatusBySliceId.set(sliceId, response.status());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,8 +18,41 @@
|
||||
*/
|
||||
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
import type { TestAssets } from '../../helpers/fixtures';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { expect, type TestAssets } from '../../helpers/fixtures';
|
||||
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import {
|
||||
apiPostDashboard,
|
||||
buildSingleRowDashboardLayout,
|
||||
type DashboardLayoutChart,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
|
||||
/**
|
||||
* Extracts the chart id that a `/api/v1/chart/data` request was issued for.
|
||||
*
|
||||
* The chart-data POST carries its slice id in the encoded
|
||||
* `form_data={"slice_id":<id>}` query param (see `chartAction.ts`). Parsing it
|
||||
* lets a test tie each request back to a specific chart and assert that every
|
||||
* chart queried — rather than only counting requests, which cannot distinguish
|
||||
* "all charts queried once" from "one chart queried twice, another skipped".
|
||||
*
|
||||
* @param url - The chart-data request or response URL
|
||||
* @returns The slice id, or undefined if the URL carries no parsable one
|
||||
*/
|
||||
export function sliceIdFromChartDataUrl(url: string): number | undefined {
|
||||
const formData = new URL(url).searchParams.get('form_data');
|
||||
if (!formData) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const sliceId = JSON.parse(formData).slice_id;
|
||||
return typeof sliceId === 'number' ? sliceId : undefined;
|
||||
} catch {
|
||||
// Not a slice-id form_data payload.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface TestDashboardResult {
|
||||
id: number;
|
||||
@@ -72,3 +105,87 @@ export async function createTestDashboard(
|
||||
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
export interface DashboardChartSpec {
|
||||
/** Sent as the chart's top-level `viz_type` and injected into its params. */
|
||||
viz_type: string;
|
||||
/**
|
||||
* Chart params minus `datasource` and `viz_type` — the helper injects both
|
||||
* (the datasource is resolved from the dataset, so callers never thread the
|
||||
* dataset id through their spec).
|
||||
*/
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface CreateDashboardWithChartsOptions {
|
||||
/** Example dataset the charts query (e.g. 'birth_names'). */
|
||||
datasetName: string;
|
||||
/** Chart slice-name prefix: `${chartNamePrefix}_${viz_type}_${suffix}`. */
|
||||
chartNamePrefix: string;
|
||||
/** Dashboard title prefix: `${dashboardTitlePrefix}_${suffix}`. */
|
||||
dashboardTitlePrefix: string;
|
||||
chartSpecs: DashboardChartSpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a published dashboard via the API: creates each chart, lays them out in
|
||||
* a single row, and associates them so they render. Every created chart and the
|
||||
* dashboard are registered for fixture cleanup. Charts are returned in the same
|
||||
* order as `chartSpecs`, so callers can pair them back to per-spec metadata by
|
||||
* index.
|
||||
*/
|
||||
export async function createDashboardWithCharts(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
options: CreateDashboardWithChartsOptions,
|
||||
): Promise<{ dashboardId: number; charts: DashboardLayoutChart[] }> {
|
||||
const dataset = await getDatasetByName(page, options.datasetName);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${options.datasetName} not found`);
|
||||
}
|
||||
const datasource = `${dataset.id}__table`;
|
||||
|
||||
// Parallel-safe suffix so chart/dashboard names never collide across workers.
|
||||
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
|
||||
|
||||
const charts: DashboardLayoutChart[] = [];
|
||||
for (const spec of options.chartSpecs) {
|
||||
const sliceName = `${options.chartNamePrefix}_${spec.viz_type}_${uniqueSuffix}`;
|
||||
const resp = await apiPostChart(page, {
|
||||
slice_name: sliceName,
|
||||
viz_type: spec.viz_type,
|
||||
datasource_id: dataset.id,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify({
|
||||
// Caller params first so the helper-owned datasource/viz_type always win
|
||||
// and a stray key in a spec cannot repoint the chart at another dataset.
|
||||
...spec.params,
|
||||
datasource,
|
||||
viz_type: spec.viz_type,
|
||||
}),
|
||||
});
|
||||
expect(resp.ok()).toBe(true);
|
||||
const chartId = await extractIdFromResponse(resp);
|
||||
testAssets.trackChart(chartId);
|
||||
charts.push({ id: chartId, sliceName });
|
||||
}
|
||||
|
||||
// Lay all charts out in a single row.
|
||||
const positionJson = buildSingleRowDashboardLayout(charts);
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `${options.dashboardTitlePrefix}_${uniqueSuffix}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
// Associate every chart with the dashboard so they actually render.
|
||||
for (const chart of charts) {
|
||||
await apiPutChart(page, chart.id, { dashboards: [dashboardId] });
|
||||
}
|
||||
|
||||
return { dashboardId, charts };
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
buildSingleRowDashboardLayout,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
@@ -118,7 +119,7 @@ testWithAssets(
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
const canvas = page.locator('[data-test="chart-container"] canvas').first();
|
||||
await canvas.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await canvas.waitFor({ state: 'visible', timeout: TIMEOUT.CHART_RENDER });
|
||||
|
||||
// Read the configured interval colors back from the rendered canvas.
|
||||
// Poll because the gauge paints shortly after the chart container appears.
|
||||
|
||||
@@ -72,6 +72,15 @@ export const TIMEOUT = {
|
||||
*/
|
||||
QUERY_EXECUTION: 30000, // 30s for SQL queries
|
||||
|
||||
/**
|
||||
* A chart going from mounted to painted with real data.
|
||||
* Navigating to a dashboard only waits for its header, so the first chart
|
||||
* asserted absorbs the whole uncached query round-trip — more than the global
|
||||
* expect timeout allows on a loaded runner. Charts query in parallel, so a
|
||||
* dashboard full of them pays this roughly once.
|
||||
*/
|
||||
CHART_RENDER: 30000, // 30s for a chart to query and paint
|
||||
|
||||
/**
|
||||
* Extended test timeout for multi-step tests (page load + query execution + assertions).
|
||||
* Use with test.setTimeout() when the default 30s test timeout is insufficient.
|
||||
@@ -89,5 +98,5 @@ export const EMBEDDED = {
|
||||
/** Timeout for dashboard content to render inside the iframe */
|
||||
DASHBOARD_RENDER: 30000, // 30s
|
||||
/** Timeout for individual chart cells to finish rendering */
|
||||
CHART_RENDER: 30000, // 30s
|
||||
CHART_RENDER: TIMEOUT.CHART_RENDER,
|
||||
} as const;
|
||||
|
||||
@@ -902,7 +902,7 @@ const SqlEditor: FC<Props> = ({
|
||||
css={css`
|
||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||
padding-top: ${theme.sizeUnit * 4}px;
|
||||
.ant-alert-action {
|
||||
.ant-alert-actions {
|
||||
align-self: center;
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -53,7 +53,7 @@ const StyledDatasourceModal = styled(Modal)`
|
||||
top: ${TOP_MARGIN_VH}vh;
|
||||
padding-bottom: 0;
|
||||
|
||||
&& .ant-modal-content {
|
||||
&& .ant-modal-container {
|
||||
max-height: ${MODAL_HEIGHT_VH}vh;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -22,7 +22,7 @@ import { css, SupersetTheme } from '@apache-superset/core/theme';
|
||||
export const antdWarningAlertStyles = (theme: SupersetTheme) => css`
|
||||
margin: ${theme.sizeUnit * 4}px 0;
|
||||
|
||||
.ant-alert-message {
|
||||
.ant-alert-title {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -32,7 +32,7 @@ const StyledAlert = styled(Alert)`
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
|
||||
& .ant-alert-action {
|
||||
& .ant-alert-actions {
|
||||
align-self: center;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -66,7 +66,7 @@ export const BaseModalWrapper = styled(StyledModal)<BaseModalWrapperProps>`
|
||||
.ant-modal-body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.ant-modal-content {
|
||||
.ant-modal-container {
|
||||
height: 100%;
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function CancelConfirmationAlert({
|
||||
css={{
|
||||
textAlign: 'left',
|
||||
flex: 1,
|
||||
'& .ant-alert-action': { alignSelf: 'center' },
|
||||
'& .ant-alert-actions': { alignSelf: 'center' },
|
||||
}}
|
||||
description={children}
|
||||
action={
|
||||
|
||||
@@ -158,7 +158,7 @@ export const antDErrorAlertStyles = (theme: SupersetTheme) => css`
|
||||
export const antdWarningAlertStyles = (theme: SupersetTheme) => css`
|
||||
margin: ${theme.sizeUnit * 4}px 0;
|
||||
|
||||
.ant-alert-message {
|
||||
.ant-alert-title {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
@@ -535,7 +535,7 @@ export const StyledCatalogTable = styled.div`
|
||||
|
||||
export const StyledUploadWrapper = styled.div`
|
||||
margin: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
.ant-progress-inner {
|
||||
.ant-progress-rail {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -140,7 +140,7 @@ function GroupListModal({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
}))}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-container')}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem name="users" label={t('Users')}>
|
||||
|
||||
@@ -60,7 +60,7 @@ export const PermissionsField = ({
|
||||
placeholder={t('Select permissions')}
|
||||
options={options}
|
||||
loading={loading}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-container')}
|
||||
data-test="permissions-select"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -202,7 +202,7 @@ function UserListModal({
|
||||
label: role.name,
|
||||
}))}
|
||||
getPopupContainer={trigger =>
|
||||
trigger.closest('.ant-modal-content')
|
||||
trigger.closest('.ant-modal-container')
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -221,7 +221,7 @@ function UserListModal({
|
||||
label: group.name,
|
||||
}))}
|
||||
getPopupContainer={trigger =>
|
||||
trigger.closest('.ant-modal-content')
|
||||
trigger.closest('.ant-modal-container')
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -139,11 +139,10 @@ const StyledContainer = styled.div`
|
||||
|
||||
&&&& .ant-steps-item-content {
|
||||
overflow: unset;
|
||||
|
||||
.ant-steps-item-description {
|
||||
margin-top: ${theme.sizeUnit}px;
|
||||
padding-bottom: ${theme.sizeUnit}px;
|
||||
}
|
||||
/* antd 6 removed .ant-steps-item-description; the description text is now
|
||||
rendered directly in .ant-steps-item-content. */
|
||||
margin-top: ${theme.sizeUnit}px;
|
||||
padding-bottom: ${theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
&&&& .ant-tooltip-open {
|
||||
|
||||
@@ -249,6 +249,11 @@ class AsyncQueryManager:
|
||||
"iat": token.get("iat"),
|
||||
"exp": token.get("exp"),
|
||||
"aud": token.get("aud"),
|
||||
# ``datasets`` and ``rev`` are optional scope claims, so tokens
|
||||
# that differ only in their dataset allowlist or revocation
|
||||
# version still derive distinct channels.
|
||||
"datasets": token.get("datasets"),
|
||||
"rev": token.get("rev"),
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
|
||||
@@ -227,22 +227,47 @@ class BaseReportState:
|
||||
def create_log(self, error_message: Optional[str] = None) -> None:
|
||||
"""
|
||||
Creates a Report execution log, uses the current computed last_value for Alerts
|
||||
|
||||
A single execution transitions through the WORKING state before reaching a
|
||||
terminal state (SUCCESS/ERROR/NOOP/GRACE). To avoid duplicated rows in the
|
||||
execution-log view (see issue #29857), the terminal result is written onto
|
||||
the WORKING "trigger" row created earlier in the same execution rather than
|
||||
inserting a second row. The WORKING row is only used as a placeholder while
|
||||
the execution is in flight (``find_last_entered_working_log`` relies on it
|
||||
for working-timeout detection), so promoting it in place keeps one row per
|
||||
execution ``uuid`` without losing that behavior. The intentional
|
||||
error-notification marker row is a terminal-to-terminal transition, so it is
|
||||
still recorded as a distinct row.
|
||||
"""
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
try:
|
||||
log = ReportExecutionLog(
|
||||
scheduled_dttm=self._scheduled_dttm,
|
||||
start_dttm=self._start_dttm,
|
||||
end_dttm=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
value=self._report_schedule.last_value,
|
||||
value_row_json=self._report_schedule.last_value_row_json,
|
||||
state=self._report_schedule.last_state,
|
||||
error_message=error_message,
|
||||
report_schedule=self._report_schedule,
|
||||
uuid=self._execution_id,
|
||||
# Reuse the in-flight WORKING trigger row for this execution, if any,
|
||||
# so a single execution surfaces as a single log entry.
|
||||
log = (
|
||||
db.session.query(ReportExecutionLog)
|
||||
.filter(
|
||||
ReportExecutionLog.uuid == self._execution_id,
|
||||
ReportExecutionLog.state == ReportState.WORKING,
|
||||
ReportExecutionLog.error_message.is_(None),
|
||||
)
|
||||
.first()
|
||||
if self._report_schedule.last_state != ReportState.WORKING
|
||||
else None
|
||||
)
|
||||
db.session.add(log)
|
||||
if log is None:
|
||||
log = ReportExecutionLog(
|
||||
scheduled_dttm=self._scheduled_dttm,
|
||||
start_dttm=self._start_dttm,
|
||||
report_schedule=self._report_schedule,
|
||||
uuid=self._execution_id,
|
||||
)
|
||||
db.session.add(log)
|
||||
log.end_dttm = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
log.value = self._report_schedule.last_value
|
||||
log.value_row_json = self._report_schedule.last_value_row_json
|
||||
log.state = self._report_schedule.last_state
|
||||
log.error_message = error_message
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
except StaleDataError as ex:
|
||||
# Report schedule was modified or deleted by another process
|
||||
|
||||
92
superset/db_engine_specs/datafusion.py
Normal file
92
superset/db_engine_specs/datafusion.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# 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 datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import types
|
||||
|
||||
from superset.constants import TimeGrain
|
||||
from superset.db_engine_specs import BaseEngineSpec
|
||||
from superset.db_engine_specs.base import DatabaseCategory
|
||||
|
||||
|
||||
class DataFusionEngineSpec(BaseEngineSpec):
|
||||
engine_name = "Apache DataFusion"
|
||||
engine = "datafusion"
|
||||
drivers = {
|
||||
"flightsql": "Arrow Flight SQL protocol for DataFusion",
|
||||
}
|
||||
default_driver = "flightsql"
|
||||
sqlalchemy_uri_placeholder = "datafusion://host:port"
|
||||
supports_file_upload = False
|
||||
|
||||
metadata = {
|
||||
"description": "DataFusion is a highly performant query engine",
|
||||
"logo": "datafusion.png",
|
||||
"homepage_url": "https://datafusion.apache.org/",
|
||||
"categories": [
|
||||
DatabaseCategory.QUERY_ENGINES,
|
||||
DatabaseCategory.OPEN_SOURCE,
|
||||
DatabaseCategory.APACHE_PROJECTS,
|
||||
],
|
||||
"pypi_packages": ["flightsql-dbapi"],
|
||||
"connection_string": "datafusion://host:port",
|
||||
"drivers": [
|
||||
{
|
||||
"name": "Arrow Flight SQL (Recommended)",
|
||||
"pypi_package": "flightsql-dbapi",
|
||||
"connection_string": "datafusion://host:port",
|
||||
"is_recommended": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
_time_grain_expressions = {
|
||||
None: "{col}",
|
||||
TimeGrain.SECOND: "DATE_TRUNC('second', {col})",
|
||||
TimeGrain.FIVE_SECONDS: "DATE_TRUNC('minute', {col}) + INTERVAL '5 seconds' * FLOOR(EXTRACT(SECOND FROM {col}) / 5)", # noqa: E501
|
||||
TimeGrain.THIRTY_SECONDS: "DATE_TRUNC('minute', {col}) + INTERVAL '30 seconds' * FLOOR(EXTRACT(SECOND FROM {col}) / 30)", # noqa: E501
|
||||
TimeGrain.MINUTE: "DATE_TRUNC('minute', {col})",
|
||||
TimeGrain.FIVE_MINUTES: "DATE_TRUNC('hour', {col}) + INTERVAL '5 minutes' * FLOOR(EXTRACT(MINUTE FROM {col}) / 5)", # noqa: E501
|
||||
TimeGrain.TEN_MINUTES: "DATE_TRUNC('hour', {col}) + INTERVAL '10 minutes' * FLOOR(EXTRACT(MINUTE FROM {col}) / 10)", # noqa: E501
|
||||
TimeGrain.FIFTEEN_MINUTES: "DATE_TRUNC('hour', {col}) + INTERVAL '15 minutes' * FLOOR(EXTRACT(MINUTE FROM {col}) / 15)", # noqa: E501
|
||||
TimeGrain.THIRTY_MINUTES: "DATE_TRUNC('hour', {col}) + INTERVAL '30 minutes' * FLOOR(EXTRACT(MINUTE FROM {col}) / 30)", # noqa: E501
|
||||
TimeGrain.HOUR: "DATE_TRUNC('hour', {col})",
|
||||
TimeGrain.DAY: "DATE_TRUNC('day', {col})",
|
||||
TimeGrain.WEEK: "DATE_TRUNC('week', {col})",
|
||||
TimeGrain.MONTH: "DATE_TRUNC('month', {col})",
|
||||
TimeGrain.QUARTER: "DATE_TRUNC('quarter', {col})",
|
||||
TimeGrain.YEAR: "DATE_TRUNC('year', {col})",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def epoch_to_dttm(cls) -> str:
|
||||
return "from_unixtime({col})"
|
||||
|
||||
@classmethod
|
||||
def convert_dttm(
|
||||
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
|
||||
) -> str | None:
|
||||
sqla_type = cls.get_sqla_column_type(target_type)
|
||||
|
||||
if isinstance(sqla_type, types.Date):
|
||||
return f"TO_DATE('{dttm.date().isoformat()}', 'YYYY-MM-DD')"
|
||||
if isinstance(sqla_type, types.DateTime):
|
||||
dttm_formatted = dttm.isoformat(sep=" ", timespec="milliseconds")
|
||||
return f"""TO_TIMESTAMP('{dttm_formatted}', 'YYYY-MM-DD HH24:MI:SS.FFF')"""
|
||||
return None
|
||||
@@ -50,7 +50,10 @@ class PooledBaseScreenshot(BaseScreenshot):
|
||||
"""
|
||||
|
||||
def get_screenshot(
|
||||
self, user: User, window_size: WindowSize | None = None
|
||||
self,
|
||||
user: User,
|
||||
window_size: WindowSize | None = None,
|
||||
log_context: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""
|
||||
Generate screenshot using pooled WebDriver with retry logic for reliability.
|
||||
@@ -58,6 +61,9 @@ class PooledBaseScreenshot(BaseScreenshot):
|
||||
Args:
|
||||
user: User context for authentication
|
||||
window_size: Optional window size override
|
||||
log_context: Accepted for signature compatibility with
|
||||
BaseScreenshot; the pooled Selenium path does not emit the
|
||||
per-tile readiness logs that use it.
|
||||
|
||||
Returns:
|
||||
Screenshot as PNG bytes or None if failed
|
||||
|
||||
@@ -614,8 +614,15 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
warning: str | None = None
|
||||
try:
|
||||
schema = cls.get_configuration_schema(parsed_config)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
warning = str(ex)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Show a stable, user-friendly message in the UI; the exception
|
||||
# detail goes to the server log below.
|
||||
warning = str(
|
||||
t(
|
||||
"Could not load metadata for this configuration; "
|
||||
"showing the default form. See the server logs for details."
|
||||
)
|
||||
)
|
||||
logger.exception(
|
||||
"Error enriching semantic layer configuration schema for type %s",
|
||||
sl_type,
|
||||
|
||||
@@ -28,6 +28,7 @@ from typing import Any, Callable, cast, Literal
|
||||
|
||||
from flask import g, has_request_context, request
|
||||
from flask_appbuilder.const import API_URI_RIS_KEY
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.extensions import stats_logger_manager
|
||||
@@ -193,11 +194,14 @@ class AbstractEventLogger(ABC):
|
||||
if user_id is None and has_request_context():
|
||||
try:
|
||||
actual_user = g.get("user", None)
|
||||
if actual_user is not None:
|
||||
# Guest/anonymous users (e.g. embedded dashboards) are never
|
||||
# DB-mapped, so adding them to the session always fails.
|
||||
# This is expected and not worth logging.
|
||||
if actual_user is not None and sa_inspect(actual_user, raiseerr=False):
|
||||
db.session.add(actual_user)
|
||||
user_id = get_user_id()
|
||||
except Exception as ex:
|
||||
logging.warning("Failed to add user to db session: %s", ex)
|
||||
logger.debug("Failed to add user to db session: %s", ex)
|
||||
user_id = None
|
||||
payload = collect_request_payload()
|
||||
if object_ref:
|
||||
|
||||
@@ -240,8 +240,8 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
alert_div.get_by_role("button").click()
|
||||
|
||||
# wait for modal to show up
|
||||
page.locator(".ant-modal-content").wait_for(state="visible")
|
||||
err_msg_div = page.locator(".ant-modal-content .ant-modal-body")
|
||||
page.locator(".ant-modal-container").wait_for(state="visible")
|
||||
err_msg_div = page.locator(".ant-modal-container .ant-modal-body")
|
||||
#
|
||||
# # collect error message
|
||||
error_messages.append(err_msg_div.text_content())
|
||||
@@ -250,10 +250,10 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
error_as_html = err_msg_div.inner_html().replace("'", "\\'")
|
||||
#
|
||||
# # close modal after collecting error messages
|
||||
page.locator(".ant-modal-content .ant-modal-close").click()
|
||||
page.locator(".ant-modal-container .ant-modal-close").click()
|
||||
#
|
||||
# # wait until the modal becomes invisible
|
||||
page.locator(".ant-modal-content").wait_for(state="detached")
|
||||
page.locator(".ant-modal-container").wait_for(state="detached")
|
||||
try:
|
||||
# Even if some errors can't be updated in the screenshot,
|
||||
# keep all the errors in the server log and do not fail the loop
|
||||
@@ -759,7 +759,7 @@ class WebDriverSelenium(WebDriverProxy):
|
||||
app.config["SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE"],
|
||||
).until(
|
||||
EC.visibility_of_any_elements_located(
|
||||
(By.CLASS_NAME, "ant-modal-content")
|
||||
(By.CLASS_NAME, "ant-modal-container")
|
||||
)
|
||||
)[0]
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# To set the images of your preferred database, you may create a mapping here with engine and locations of the relevant images. The image can be hosted locally inside your static/file directory or online (e.g. S3).
|
||||
|
||||
# DB_IMAGES:
|
||||
# datafusion: "path/to/image/datafusion.jpg"
|
||||
# postgresql: "path/to/image/postgres.jpg"
|
||||
# bigquery: "path/to/s3bucket/bigquery.jpg"
|
||||
# snowflake: "path/to/image/snowflake.jpg"
|
||||
|
||||
@@ -21,10 +21,11 @@ from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask import current_app # noqa: F401
|
||||
from flask import current_app, g # noqa: F401
|
||||
from freezegun import freeze_time
|
||||
|
||||
from superset import security_manager
|
||||
from superset.security.guest_token import GuestUser
|
||||
from superset.utils.log import (
|
||||
AbstractEventLogger,
|
||||
DBEventLogger,
|
||||
@@ -231,6 +232,88 @@ class TestEventLogger(unittest.TestCase):
|
||||
|
||||
assert logger.records[0]["user_id"] == None # noqa: E711
|
||||
|
||||
def test_log_with_context_guest_user_skips_warning(self):
|
||||
"""Guest/anonymous users are never DB-mapped; adding them to the
|
||||
session is expected to be a no-op, not a warning-worthy failure."""
|
||||
|
||||
class DummyEventLogger(AbstractEventLogger):
|
||||
def __init__(self):
|
||||
self.records = []
|
||||
|
||||
def log(
|
||||
self,
|
||||
user_id: Optional[int],
|
||||
action: str,
|
||||
dashboard_id: Optional[int],
|
||||
duration_ms: Optional[int],
|
||||
slice_id: Optional[int],
|
||||
referrer: Optional[str],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self.records.append(
|
||||
{**kwargs, "user_id": user_id, "duration": duration_ms}
|
||||
)
|
||||
|
||||
logger = DummyEventLogger()
|
||||
|
||||
with app.test_request_context():
|
||||
g.user = GuestUser(
|
||||
token={"user": {"username": "guest"}, "resources": []},
|
||||
roles=[],
|
||||
)
|
||||
with self.assertNoLogs(level="WARNING"):
|
||||
logger.log_with_context(
|
||||
action="foo",
|
||||
duration=timedelta(seconds=1),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
|
||||
assert logger.records[0]["user_id"] is None
|
||||
|
||||
@patch("superset.db")
|
||||
def test_log_with_context_unexpected_add_failure_logs_debug(self, mock_db):
|
||||
"""A genuinely unexpected failure (not the guest-user case) should
|
||||
still be surfaced, but at debug level rather than warning."""
|
||||
|
||||
class DummyEventLogger(AbstractEventLogger):
|
||||
def __init__(self):
|
||||
self.records = []
|
||||
|
||||
def log(
|
||||
self,
|
||||
user_id: Optional[int],
|
||||
action: str,
|
||||
dashboard_id: Optional[int],
|
||||
duration_ms: Optional[int],
|
||||
slice_id: Optional[int],
|
||||
referrer: Optional[str],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self.records.append(
|
||||
{**kwargs, "user_id": user_id, "duration": duration_ms}
|
||||
)
|
||||
|
||||
logger = DummyEventLogger()
|
||||
mock_db.session.add.side_effect = Exception("boom")
|
||||
|
||||
with app.test_request_context():
|
||||
# A DB-mapped (but unpersisted) user instance so sa_inspect()
|
||||
# treats it as mapped and the code proceeds to db.session.add(),
|
||||
# independent of any test-database fixture data.
|
||||
g.user = security_manager.user_model()
|
||||
with self.assertNoLogs(level="WARNING"):
|
||||
with self.assertLogs("superset.utils.log", level="DEBUG") as debug_logs:
|
||||
logger.log_with_context(
|
||||
action="foo",
|
||||
duration=timedelta(seconds=1),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
|
||||
assert "Failed to add user to db session" in debug_logs.output[0]
|
||||
assert logger.records[0]["user_id"] is None
|
||||
|
||||
@patch.object(DBEventLogger, "log")
|
||||
def test_log_this_with_context_and_extra_payload(self, mock_log):
|
||||
logger = DBEventLogger()
|
||||
|
||||
@@ -19,7 +19,7 @@ from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from unittest.mock import call, Mock, patch
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from flask.ctx import AppContext
|
||||
@@ -161,15 +161,26 @@ def assert_log(state: str, error_message: Optional[str] = None):
|
||||
db.session.commit()
|
||||
logs = db.session.query(ReportExecutionLog).all()
|
||||
|
||||
if state == ReportState.ERROR:
|
||||
# On error we send an email
|
||||
assert len(logs) == 3
|
||||
else:
|
||||
if state == ReportState.WORKING:
|
||||
# A report that is already in the WORKING state logs an extra WORKING row
|
||||
# for the refused re-computation, on top of the row seeded by the fixture.
|
||||
assert len(logs) == 2
|
||||
elif state == ReportState.ERROR:
|
||||
# On error we also send a notification, which is recorded as a separate
|
||||
# error-notification marker row.
|
||||
assert len(logs) == 2
|
||||
else:
|
||||
# A single execution yields a single log row: the terminal result replaces
|
||||
# the WORKING "trigger" row rather than adding a second row (issue #29857).
|
||||
assert len(logs) == 1
|
||||
log_states = [log.state for log in logs]
|
||||
assert ReportState.WORKING in log_states
|
||||
assert state in log_states
|
||||
assert error_message in [log.error_message for log in logs]
|
||||
# Previously a standalone WORKING "trigger" row always contributed a ``None``
|
||||
# error_message, so the default ``None`` match was trivially satisfied and never
|
||||
# verified the terminal row. With the result now written onto that single row,
|
||||
# only assert an explicitly expected message.
|
||||
if error_message is not None:
|
||||
assert error_message in [log.error_message for log in logs]
|
||||
|
||||
for log in logs:
|
||||
if log.state == ReportState.WORKING:
|
||||
@@ -805,6 +816,45 @@ def test_email_chart_report_schedule(
|
||||
assert_log(ReportState.SUCCESS)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"load_birth_names_dashboard_with_slices", "create_report_email_chart"
|
||||
)
|
||||
@patch("superset.reports.notifications.email.send_email_smtp")
|
||||
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
||||
def test_email_chart_report_schedule_single_log_per_execution(
|
||||
screenshot_mock,
|
||||
email_mock,
|
||||
create_report_email_chart,
|
||||
):
|
||||
"""
|
||||
ExecuteReport Command: a single execution should produce a single log row.
|
||||
|
||||
Regression for #29857: the Alerts & Reports execution log shows duplicated
|
||||
entries for a single execution. Each execution transitions through the
|
||||
WORKING state and then a terminal state (SUCCESS/ERROR), and every
|
||||
transition writes a ReportExecutionLog row sharing the same execution
|
||||
``uuid``. As a result one execution surfaces as two rows in the log view
|
||||
(the "trigger" row and the "result" row). This test asserts that one
|
||||
execution -- identified by its execution uuid -- yields exactly one log row.
|
||||
"""
|
||||
screenshot_mock.return_value = SCREENSHOT_FILE
|
||||
|
||||
with freeze_time("2020-01-01T00:00:00Z"):
|
||||
AsyncExecuteReportScheduleCommand(
|
||||
TEST_ID, create_report_email_chart.id, datetime.utcnow()
|
||||
).run()
|
||||
|
||||
db.session.commit()
|
||||
logs = (
|
||||
db.session.query(ReportExecutionLog)
|
||||
.filter(ReportExecutionLog.uuid == UUID(TEST_ID))
|
||||
.all()
|
||||
)
|
||||
# A single execution (one uuid) must map to a single log entry, not one
|
||||
# row per intermediate state transition.
|
||||
assert len(logs) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"load_birth_names_dashboard_with_slices", "create_report_email_chart_alpha_owner"
|
||||
)
|
||||
|
||||
@@ -223,6 +223,44 @@ def test_parse_channel_id_from_request_as_guest_user_differs_per_token(
|
||||
assert first != second
|
||||
|
||||
|
||||
@mock.patch("superset.is_feature_enabled")
|
||||
def test_parse_channel_id_from_request_as_guest_user_differs_per_scope(
|
||||
is_feature_enabled_mock, async_query_manager
|
||||
):
|
||||
"""
|
||||
Tokens that differ only in the optional ``datasets`` allowlist or ``rev``
|
||||
revocation version must still derive distinct channel ids, otherwise two
|
||||
differently scoped embedded sessions would collide on the same stream.
|
||||
"""
|
||||
is_feature_enabled_mock.return_value = True
|
||||
|
||||
base_token = {
|
||||
"user": {},
|
||||
"resources": [{"type": "dashboard", "id": "some-uuid"}],
|
||||
"rls_rules": [{"clause": '"STATEID" = 3'}],
|
||||
"iat": 1700000000.0,
|
||||
"exp": 1700000300.0,
|
||||
"aud": "http://0.0.0.0:8080/",
|
||||
"type": "guest",
|
||||
}
|
||||
|
||||
request = Mock()
|
||||
request.cookies = {}
|
||||
|
||||
g.user = security_manager.get_guest_user_from_token(dict(base_token))
|
||||
baseline = async_query_manager.parse_channel_id_from_request(request)
|
||||
|
||||
g.user = security_manager.get_guest_user_from_token({**base_token, "datasets": [1]})
|
||||
with_datasets = async_query_manager.parse_channel_id_from_request(request)
|
||||
|
||||
g.user = security_manager.get_guest_user_from_token({**base_token, "rev": 1})
|
||||
with_rev = async_query_manager.parse_channel_id_from_request(request)
|
||||
|
||||
assert baseline != with_datasets
|
||||
assert baseline != with_rev
|
||||
assert with_datasets != with_rev
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"cache_type, cache_backend",
|
||||
[
|
||||
|
||||
@@ -2468,6 +2468,9 @@ def test_create_log_success_commits(mocker: MockerFixture) -> None:
|
||||
state._report_schedule = schedule
|
||||
|
||||
mock_db = mocker.patch("superset.commands.report.execute.db")
|
||||
# No in-flight WORKING "trigger" row exists for this execution, so create_log
|
||||
# inserts a fresh row rather than promoting an existing one.
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = None
|
||||
mock_log_cls = mocker.patch(
|
||||
"superset.commands.report.execute.ReportExecutionLog",
|
||||
return_value=mocker.Mock(),
|
||||
|
||||
101
tests/unit_tests/db_engine_specs/test_datafusion.py
Normal file
101
tests/unit_tests/db_engine_specs/test_datafusion.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# 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 datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.db_engine_specs.datafusion import DataFusionEngineSpec
|
||||
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
|
||||
from tests.unit_tests.fixtures.common import dttm # noqa: F401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target_type,expected_result",
|
||||
[
|
||||
("Date", "TO_DATE('2019-01-02', 'YYYY-MM-DD')"),
|
||||
(
|
||||
"DateTime",
|
||||
"TO_TIMESTAMP('2019-01-02 03:04:05.678', 'YYYY-MM-DD HH24:MI:SS.FFF')",
|
||||
),
|
||||
(
|
||||
"TimeStamp",
|
||||
"TO_TIMESTAMP('2019-01-02 03:04:05.678', 'YYYY-MM-DD HH24:MI:SS.FFF')",
|
||||
),
|
||||
("UnknownType", None),
|
||||
],
|
||||
)
|
||||
def test_convert_dttm(
|
||||
target_type: str,
|
||||
expected_result: str | None,
|
||||
dttm: datetime, # noqa: F811
|
||||
) -> None:
|
||||
assert_convert_dttm(DataFusionEngineSpec, target_type, expected_result, dttm)
|
||||
|
||||
|
||||
def test_epoch_to_dttm() -> None:
|
||||
assert DataFusionEngineSpec.epoch_to_dttm().format(col="ts") == "from_unixtime(ts)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time_grain,expected",
|
||||
[
|
||||
(None, "ts"),
|
||||
("PT1S", "DATE_TRUNC('second', ts)"),
|
||||
(
|
||||
"PT5S",
|
||||
"DATE_TRUNC('minute', ts) + INTERVAL '5 seconds' * "
|
||||
"FLOOR(EXTRACT(SECOND FROM ts) / 5)",
|
||||
),
|
||||
(
|
||||
"PT30S",
|
||||
"DATE_TRUNC('minute', ts) + INTERVAL '30 seconds' * "
|
||||
"FLOOR(EXTRACT(SECOND FROM ts) / 30)",
|
||||
),
|
||||
("PT1M", "DATE_TRUNC('minute', ts)"),
|
||||
(
|
||||
"PT5M",
|
||||
"DATE_TRUNC('hour', ts) + INTERVAL '5 minutes' * "
|
||||
"FLOOR(EXTRACT(MINUTE FROM ts) / 5)",
|
||||
),
|
||||
(
|
||||
"PT10M",
|
||||
"DATE_TRUNC('hour', ts) + INTERVAL '10 minutes' * "
|
||||
"FLOOR(EXTRACT(MINUTE FROM ts) / 10)",
|
||||
),
|
||||
(
|
||||
"PT15M",
|
||||
"DATE_TRUNC('hour', ts) + INTERVAL '15 minutes' * "
|
||||
"FLOOR(EXTRACT(MINUTE FROM ts) / 15)",
|
||||
),
|
||||
(
|
||||
"PT30M",
|
||||
"DATE_TRUNC('hour', ts) + INTERVAL '30 minutes' * "
|
||||
"FLOOR(EXTRACT(MINUTE FROM ts) / 30)",
|
||||
),
|
||||
("PT1H", "DATE_TRUNC('hour', ts)"),
|
||||
("P1D", "DATE_TRUNC('day', ts)"),
|
||||
("P1W", "DATE_TRUNC('week', ts)"),
|
||||
("P1M", "DATE_TRUNC('month', ts)"),
|
||||
("P3M", "DATE_TRUNC('quarter', ts)"),
|
||||
("P1Y", "DATE_TRUNC('year', ts)"),
|
||||
],
|
||||
)
|
||||
def test_time_grain_expressions(time_grain: str | None, expected: str) -> None:
|
||||
assert (
|
||||
DataFusionEngineSpec._time_grain_expressions[time_grain].format(col="ts")
|
||||
== expected
|
||||
)
|
||||
36
tests/unit_tests/mcp_service/test_pooled_screenshot.py
Normal file
36
tests/unit_tests/mcp_service/test_pooled_screenshot.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from superset.mcp_service.screenshot.pooled_screenshot import PooledBaseScreenshot
|
||||
|
||||
|
||||
@patch("superset.mcp_service.screenshot.pooled_screenshot.retry_screenshot_operation")
|
||||
def test_get_screenshot_accepts_base_log_context(
|
||||
mock_retry_screenshot_operation: MagicMock,
|
||||
) -> None:
|
||||
screenshot = PooledBaseScreenshot("http://example.com", "digest")
|
||||
user = MagicMock()
|
||||
|
||||
screenshot.get_screenshot(user, log_context="cache_key=abc")
|
||||
|
||||
mock_retry_screenshot_operation.assert_called_once_with(
|
||||
screenshot._get_screenshot_internal, # pylint: disable=protected-access
|
||||
user,
|
||||
None,
|
||||
)
|
||||
@@ -1186,7 +1186,12 @@ def test_configuration_schema_enrichment_error_fallback(
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["result"] == {"type": "object"}
|
||||
assert response.json["warning"] == "connection failed"
|
||||
# The warning is a stable, user-friendly message; the exception detail
|
||||
# only goes to the server log.
|
||||
assert response.json["warning"] == (
|
||||
"Could not load metadata for this configuration; "
|
||||
"showing the default form. See the server logs for details."
|
||||
)
|
||||
assert mock_cls.get_configuration_schema.call_count == 2
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user