Compare commits

...
Author SHA1 Message Date
Beto Dealmeida 503933756e fix: show only filterable columns on filter dropdown 2025-05-01 17:20:02 -04:00
Beto Dealmeida 60261a5dc6 Fix docstring 2025-05-01 12:33:44 -04:00
Beto Dealmeida 456512c508 Fix test 2025-05-01 11:56:13 -04:00
Beto Dealmeida c1d9b06649 Remove old method 2025-05-01 09:49:50 -04:00
Beto Dealmeida 7a64a82cd9 fix: improve function detection 2025-04-30 16:58:11 -04:00
JUST.in DO IT ef14b529b8 fix(echarts): rename time series shifted colnames (#33269) 2025-04-30 14:18:18 -03:00
github-actions[bot]andGitHub Action 2a97a6ec1f chore(🦾): bump python importlib-metadata 8.6.1 -> 8.7.0 (#33277)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 10:03:18 -07:00
github-actions[bot]andGitHub Action fa6548939e chore(🦾): bump python mako 1.3.9 -> 1.3.10 (#33280)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 10:02:45 -07:00
github-actions[bot]andGitHub Action 418c673699 chore(🦾): bump python pyparsing 3.2.2 -> 3.2.3 (#33281)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 10:01:42 -07:00
github-actions[bot]andGitHub Action 13f77a7416 chore(🦾): bump python celery 5.4.0 -> 5.5.2 (#33257)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:28:39 -07:00
github-actions[bot]andGitHub Action 303a80a316 chore(🦾): bump python packaging 24.2 -> 25.0 (#33259)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:27:48 -07:00
github-actions[bot]andGitHub Action 2392ac6827 chore(🦾): bump python deprecation subpackage(s) (#33260)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:27:25 -07:00
github-actions[bot]andGitHub Action 01ce4b987e chore(🦾): bump python python-dotenv 1.0.1 -> 1.1.0 (#33262)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:26:59 -07:00
github-actions[bot]andGitHub Action 2f308a85d8 chore(🦾): bump python pandas subpackage(s) (#33263)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:26:40 -07:00
github-actions[bot]andGitHub Action e8d60509a0 chore(🦾): bump python sqlglot 26.11.1 -> 26.16.2 (#33266)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:26:01 -07:00
github-actions[bot]andGitHub Action d6f80eaae7 chore(🦾): bump python gunicorn subpackage(s) (#33265)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:25:23 -07:00
Emad Rad a5f986fec5 feat: Persian translations (#29580) 2025-04-29 09:01:34 -06:00
Beto Dealmeida 141d0252f2 fix: mask password on DB import (#33267) 2025-04-29 10:27:03 -04:00
Daniel Vaz Gaspar c029b532d4 fix: LocalProxy is not mapped warning (#33025) 2025-04-28 23:01:26 -06:00
github-actions[bot]andGitHub Action 13816443ba chore(🦾): bump python croniter subpackage(s) (#33258)
Co-authored-by: GitHub Action <action@github.com>
2025-04-28 16:52:09 -07:00
Elizabeth Thompson 2c4e22e598 chore: add some utils tests (#33236) 2025-04-28 15:00:32 -07:00
Hamir Mahal aea776a131 fix: Unexpected input(s) 'depth' CI warnings (#33254) 2025-04-28 11:07:13 -06:00
Evan Rusackas d2360b533b fix(histogram): remove extra single quotes (#33248) 2025-04-25 16:45:05 -06:00
Vitor Avila de84a534ac fix(DB update): Gracefully handle querry error during DB update (#33250) 2025-04-25 15:38:59 -03:00
Sam Firke ac636c73ae fix(heatmap): correctly render int and boolean falsy values on axes (#33238) 2025-04-25 11:25:50 -04:00
Levis Mbote 6a586fe4fd fix(chart): Restore subheader used in bignumber with trendline (#33196) 2025-04-25 09:39:07 -03:00
Vitor Avila fbd8ae2888 fix(sqllab permalink): Commit SQL Lab permalinks (#33237) 2025-04-24 22:41:15 -03:00
Vitor Avila 7e4fde7a14 fix(standalone): Ensure correct URL param value for standalone mode (#33234) 2025-04-24 16:41:42 -03:00
Evan Rusackasanddykoffi 150b9a0168 feat(maps): Adding Republic of Serbia to country maps (#33208)
Co-authored-by: dykoffi <dykoffi@users.noreply.github.com>
2025-04-23 11:29:35 -06:00
Vitor Avila f7b7aace38 fix(export): Full CSV/Excel exports respecting SQL_MAX_ROW config (#33214) 2025-04-23 13:13:07 -03:00
Sam Firke f78c94c988 docs(installation): compare installation methods (#33137) 2025-04-23 11:57:33 -04:00
sha174n 74ff8dc724 docs: Add note on SQL execution security considerations (#33210) 2025-04-23 13:58:33 +01:00
Shao Yu-Lung (Allen)andShao Yu-Lung 8aa127eac2 feat(i18n): Frontend add zh_TW Option (#33192)
Co-authored-by: Shao Yu-Lung (Allen) <mis@cendai.com.tw>
2025-04-22 15:36:09 -06:00
KalaiandEvan Rusackas 3729016a0d docs: improve documentation(docs): clarify URL encoding requirement for connection strings (#30047)
Co-authored-by: Evan Rusackas <evan@preset.io>
2025-04-22 15:30:19 -06:00
Elizabeth Thompson b6628cdfd2 chore: migrate to more db migration utils (#33155) 2025-04-22 11:26:54 -07:00
Evan Rusackasanddykoffi ae48dba3e1 feat(maps): Adding Ivory Coast / Côte d'Ivoire (#33198)
Co-authored-by: dykoffi <dykoffi@users.noreply.github.com>
2025-04-22 10:04:19 -06:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 09364d182c chore(deps-dev): bump http-proxy-middleware from 2.0.7 to 2.0.9 in /superset-frontend (#33197)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 09:31:10 -06:00
Geido 99ed968289 fix(Native Filters): Keep default filter values when configuring creatable behavior (#33205) 2025-04-22 16:32:30 +02:00
Geido 8fa3b8d7e3 fix(Native Filters): Keep default filter values when configuring creatable behavior (#33205) 2025-04-22 16:30:36 +02:00
Maxime Alay-EddineandMaxime ALAY-EDDINE 7530487760 feat(country-map): fix France Regions IDF region code - Fixes #32627 (#32695)
Co-authored-by: Maxime ALAY-EDDINE <maxime@galeax.com>
2025-04-21 20:15:27 -06:00
Maxime Beauchemin 79afc2b545 docs: add a high-level architecture diagram to the docs (#33173) 2025-04-21 11:15:29 -07:00
JUST.in DO IT 8c94f9c435 fix(sqllab): Invalid SQL Error breaks SQL Lab (#33164) 2025-04-18 13:31:54 -07:00
Evan Rusackas b589d44dfb fix(deckgl): Update Arc to properly adjust line width (#33154) 2025-04-18 10:07:40 -06:00
Elizabeth Thompson 4140261797 fix: subheader should show as subtitle (#33172) 2025-04-18 13:03:20 +08:00
89 changed files with 45876 additions and 23824 deletions
+5
View File
@@ -127,6 +127,11 @@
- any-glob-to-any-file:
- 'superset/translations/es/**'
"i18n:persian":
- changed-files:
- any-glob-to-any-file:
- 'superset/translations/fa/**'
############################################
# Sub-projects and monorepo packages
############################################
+1 -2
View File
@@ -17,13 +17,12 @@ jobs:
check-python-deps:
runs-on: ubuntu-22.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
depth: 1
fetch-depth: 1
- name: Setup Python
if: steps.check.outputs.python
+2
View File
@@ -86,6 +86,7 @@
"Israel",
"Italy",
"Italy (regions)",
"Ivory Coast",
"Japan",
"Jordan",
"Kazakhstan",
@@ -143,6 +144,7 @@
"Poland",
"Portugal",
"Qatar",
"Republic Of Serbia",
"Romania",
"Russia",
"Rwanda",
+7
View File
@@ -1293,6 +1293,13 @@ The connection string for SQL Server looks like this:
mssql+pyodbc:///?odbc_connect=Driver%3D%7BODBC+Driver+17+for+SQL+Server%7D%3BServer%3Dtcp%3A%3Cmy_server%3E%2C1433%3BDatabase%3Dmy_database%3BUid%3Dmy_user_name%3BPwd%3Dmy_password%3BEncrypt%3Dyes%3BConnection+Timeout%3D30
```
:::note
You might have noticed that some special charecters are used in the above connection string. For example see the `odbc_connect` parameter. The value is `Driver%3D%7BODBC+Driver+17+for+SQL+Server%7D%3B` which is a URL-encoded form of `Driver={ODBC+Driver+17+for+SQL+Server};`. It's important to give the connection string is URL encoded.
For more information about this check the [sqlalchemy documentation](https://docs.sqlalchemy.org/en/20/core/engines.html#escaping-special-characters-such-as-signs-in-passwords). Which says `When constructing a fully formed URL string to pass to create_engine(), special characters such as those that may be used in the user and password need to be URL encoded to be parsed correctly. This includes the @ sign.`
:::
#### StarRocks
The [sqlalchemy-starrocks](https://pypi.org/project/starrocks/) library is the recommended
+86
View File
@@ -4,9 +4,95 @@ version: 1
---
import InteractiveSVG from '../../src/components/InteractiveERDSVG';
import Mermaid from '@theme/Mermaid';
# Resources
## High Level Architecture
<div style={{ maxWidth: "600px", margin: "0 auto", marginLeft: 0, marginRight: "auto" }}>
```mermaid
flowchart TD
%% Top Level
LB["<b>Load Balancer(s)</b><br/>(optional)"]
LB -.-> WebServers
%% Web Servers
subgraph WebServers ["<b>Web Server(s)</b>"]
WS1["<b>Frontend</b><br/>(React, AntD, ECharts, AGGrid)"]
WS2["<b>Backend</b><br/>(Python, Flask, SQLAlchemy, Pandas, ...)"]
end
%% Infra
subgraph InfraServices ["<b>Infra</b>"]
DB[("<b>Metadata Database</b><br/>(Postgres / MySQL)")]
subgraph Caching ["<b>Caching Subservices<br/></b>(Redis, memcache, S3, ...)"]
direction LR
DummySpace[" "]:::invisible
QueryCache["<b>Query Results Cache</b><br/>(Accelerated Dashboards)"]
CsvCache["<b>CSV Exports Cache</b>"]
ThumbnailCache["<b>Thumbnails Cache</b>"]
AlertImageCache["<b>Alert/Report Images Cache</b>"]
QueryCache -- " " --> CsvCache
linkStyle 1 stroke:transparent;
ThumbnailCache -- " " --> AlertImageCache
linkStyle 2 stroke:transparent;
end
Broker(("<b>Message Queue</b><br/>(Redis / RabbitMQ / SQS)"))
end
AsyncBackend["<b>Async Workers (Celery)</b><br>required for Alerts & Reports, thumbnails, CSV exports, long-running workloads, ..."]
%% External DBs
subgraph ExternalDatabases ["<b>Analytics Databases</b>"]
direction LR
BigQuery[(BigQuery)]
Snowflake[(Snowflake)]
Redshift[(Redshift)]
Postgres[(Postgres)]
Postgres[(... any ...)]
end
%% Connections
LB -.-> WebServers
WebServers --> DB
WebServers -.-> Caching
WebServers -.-> Broker
WebServers -.-> ExternalDatabases
Broker -.-> AsyncBackend
AsyncBackend -.-> ExternalDatabases
AsyncBackend -.-> Caching
%% Legend styling
classDef requiredNode stroke-width:2px,stroke:black;
class Required requiredNode;
class Optional optionalNode;
%% Hide real arrow
linkStyle 0 stroke:transparent;
%% Styling
classDef optionalNode stroke-dasharray: 5 5, opacity:0.9;
class LB optionalNode;
class Caching optionalNode;
class AsyncBackend optionalNode;
class Broker optionalNode;
class QueryCache optionalNode;
class CsvCache optionalNode;
class ThumbnailCache optionalNode;
class AlertImageCache optionalNode;
class Celery optionalNode;
classDef invisible fill:transparent,stroke:transparent;
```
</div>
## Entity-Relationship Diagram
Here is our interactive ERD:
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Docker Builds
hide_title: true
sidebar_position: 6
sidebar_position: 7
version: 1
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Docker Compose
hide_title: true
sidebar_position: 4
sidebar_position: 5
version: 1
---
@@ -0,0 +1,58 @@
---
title: Installation Methods
hide_title: true
sidebar_position: 2
version: 1
---
import useBaseUrl from "@docusaurus/useBaseUrl";
# Installation Methods
How should you install Superset? Here's a comparison of the different options. It will help if you've first read the [Architecture](/docs/installation/architecture.mdx) page to understand Superset's different components.
The fundamental trade-off is between you needing to do more of the detail work yourself vs. using a more complex deployment route that handles those details.
## [Docker Compose](/docs/installation/docker-compose.mdx)
**Summary:** This takes advantage of containerization while remaining simpler than Kubernetes. This is the best way to try out Superset; it's also useful for developing & contributing back to Superset.
If you're not just demoing the software, you'll need a moderate understanding of Docker to customize your deployment and avoid a few risks. Even when fully-optimized this is not as robust a method as Kubernetes when it comes to large-scale production deployments.
You manage a superset-config.py file and a docker-compose.yml file. Docker Compose brings up all the needed services - the Superset application, a Postgres metadata DB, Redis cache, Celery worker and beat. They are automatically connected to each other.
**Responsibilities**
You will need to back up your metadata DB. That could mean backing up the service running as a Docker container and its volume; ideally you are running Postgres as a service outside of that container and backing up that service.
You will also need to extend the Superset docker image. The default `lean` images do not contain drivers needed to access your metadata database (Postgres or MySQL), nor to access your data warehouse, nor the headless browser needed for Alerts & Reports. You could run a `-dev` image while demoing Superset, which has some of this, but you'll still need to install the driver for your data warehouse. The `-dev` images run as root, which is not recommended for production.
Ideally you will build your own image of Superset that extends `lean`, adding what your deployment needs.
See [Docker Build Presets](/docs/installation/docker-builds/#build-presets) for more information about the different image versions you can extend.
## [Kubernetes (K8s)](/docs/installation/kubernetes.mdx)
**Summary:** This is the best-practice way to deploy a production instance of Superset, but has the steepest skill requirement - someone who knows Kubernetes.
You will deploy Superset into a K8s cluster. The most common method is using the community-maintained Helm chart, though work is now underway to implement [SIP-149 - a Kubernetes Operator for Superset](https://github.com/apache/superset/issues/31408).
A K8s deployment can scale up and down based on usage and deploy rolling updates with zero downtime - features that big deployments appreciate.
**Responsibilities**
You will need to build your own Docker image, and back up your metadata DB, both as described in Docker Compose above. You'll also need to customize your Helm chart values and deploy and maintain your Kubernetes cluster.
## [PyPI (Python)](/docs/installation/pypi.mdx)
**Summary:** This is the only method that requires no knowledge of containers. It requires the most hands-on work to deploy, connect, and maintain each component.
You install Superset as a Python package and run it that way, providing your own metadata database. Superset has documentation on how to install this way, but it is updated infrequently.
If you want caching, you'll set up Redis or RabbitMQ. If you want Alerts & Reports, you'll set up Celery.
**Responsibilities**
You will need to get the component services running and communicating with each other. You'll need to arrange backups of your metadata database.
When upgrading, you'll need to manage the system environment and packages and ensure all components have functional dependencies.
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Kubernetes
hide_title: true
sidebar_position: 2
sidebar_position: 3
version: 1
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: PyPI
hide_title: true
sidebar_position: 3
sidebar_position: 4
version: 1
---
@@ -1,7 +1,7 @@
---
title: Upgrading Superset
hide_title: true
sidebar_position: 5
sidebar_position: 6
version: 1
---
+20
View File
@@ -64,6 +64,26 @@ tables in the **Permissions** dropdown. To select the data sources you want to a
You can then confirm with users assigned to the **Gamma** role that they see the
objects (dashboards and slices) associated with the tables you just extended them.
### SQL Execution Security Considerations
Apache Superset includes features designed to provide safeguards when interacting with connected databases, such as the `DISALLOWED_SQL_FUNCTIONS` configuration setting. This aims to prevent the execution of potentially harmful database functions or system variables directly from Superset interfaces like SQL Lab.
However, it is crucial to understand the following:
**Superset is Not a Database Firewall**: Superset's built-in checks, like `DISALLOWED_SQL_FUNCTIONS`, provide a layer of protection but cannot guarantee complete security against all database-level threats or advanced bypass techniques (like specific comment injection methods). They should be viewed as a supplement to, not a replacement for, robust database security.
**Configuration is Key**: The effectiveness of Superset's safeguards heavily depends on proper configuration by the Superset administrator. This includes maintaining the `DISALLOWED_SQL_FUNCTIONS` list, carefully managing feature flags (like `ENABLE_TEMPLATE_PROCESSING`), and configuring other security settings appropriately.
**Database Security is Paramount**: The ultimate responsibility for securing database access, controlling permissions, and preventing unauthorized function execution lies with the database administrators (DBAs) and security teams managing the underlying database instance.
**Recommended Database Practices**: We strongly recommend implementing security best practices at the database level, including:
* **Least Privilege**: Connecting Superset using dedicated database user accounts with the minimum permissions required for Superset's operation (typically read-only access to necessary schemas/tables).
* **Database Roles & Permissions**: Utilizing database-native roles and permissions to restrict access to sensitive functions, system variables (like `@@hostname`), schemas, or tables.
* **Network Security**: Employing network-level controls like database firewalls or proxies to restrict connections.
* **Auditing**: Enabling database-level auditing to monitor executed queries and access patterns.
By combining Superset's configurable safeguards with strong database-level security practices, you can achieve a more robust and layered security posture.
### REST API for user & role management
Flask-AppBuilder supports a REST API for user CRUD,
+4 -1
View File
@@ -31,10 +31,13 @@ const config: Config = {
baseUrl: '/',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'throw',
markdown: {
mermaid: true,
},
favicon: '/img/favicon.ico',
organizationName: 'apache',
projectName: 'superset',
themes: ['@saucelabs/theme-github-codeblock'],
themes: ['@saucelabs/theme-github-codeblock', '@docusaurus/theme-mermaid'],
plugins: [
[
'docusaurus-plugin-less',
+4 -3
View File
@@ -19,9 +19,10 @@
},
"dependencies": {
"@ant-design/icons": "^5.5.2",
"@docusaurus/core": "^3.5.2",
"@docusaurus/plugin-client-redirects": "^3.5.2",
"@docusaurus/preset-classic": "^3.5.2",
"@docusaurus/core": "3.7.0",
"@docusaurus/plugin-client-redirects": "3.7.0",
"@docusaurus/preset-classic": "3.7.0",
"@docusaurus/theme-mermaid": "3.7.0",
"@emotion/styled": "^10.0.27",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@superset-ui/style": "^0.14.23",
+1 -1
View File
@@ -111,7 +111,7 @@ const StyledTitleContainer = styled('div')`
}
`;
const StyledButton = styled(Link)`
const StyledButton = styled(Link as React.ComponentType<any>)`
border-radius: 10px;
font-size: 20px;
font-weight: bold;
+4142 -2780
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -44,7 +44,7 @@ cachetools==5.5.2
# via google-auth
cattrs==24.1.2
# via requests-cache
celery==5.4.0
celery==5.5.2
# via apache-superset (pyproject.toml)
certifi==2025.1.31
# via
@@ -158,6 +158,7 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.14.0
@@ -173,7 +174,7 @@ idna==3.10
# email-validator
# requests
# trio
importlib-metadata==8.6.1
importlib-metadata==8.7.0
# via apache-superset (pyproject.toml)
isodate==0.7.2
# via apache-superset (pyproject.toml)
@@ -191,13 +192,13 @@ jsonschema==4.23.0
# via flask-appbuilder
jsonschema-specifications==2024.10.1
# via jsonschema
kombu==5.5.0
kombu==5.5.3
# via celery
korean-lunar-calendar==0.3.1
# via holidays
limits==4.4.1
limits==5.1.0
# via flask-limiter
mako==1.3.9
mako==1.3.10
# via
# apache-superset (pyproject.toml)
# alembic
@@ -244,7 +245,7 @@ ordered-set==4.1.0
# via flask-limiter
outcome==1.3.0.post0
# via trio
packaging==24.2
packaging==25.0
# via
# apache-superset (pyproject.toml)
# apispec
@@ -271,7 +272,7 @@ polyline==2.0.2
# via apache-superset (pyproject.toml)
prison==0.2.1
# via flask-appbuilder
prompt-toolkit==3.0.50
prompt-toolkit==3.0.51
# via click-repl
pyarrow==14.0.2
# via apache-superset (pyproject.toml)
@@ -294,7 +295,7 @@ pynacl==1.5.0
# via paramiko
pyopenssl==25.0.0
# via shillelagh
pyparsing==3.2.2
pyparsing==3.2.3
# via apache-superset (pyproject.toml)
pysocks==1.7.1
# via urllib3
@@ -307,11 +308,11 @@ python-dateutil==2.9.0.post0
# holidays
# pandas
# shillelagh
python-dotenv==1.0.1
python-dotenv==1.1.0
# via apache-superset (pyproject.toml)
python-geohash==0.8.5
# via apache-superset (pyproject.toml)
pytz==2025.1
pytz==2025.2
# via
# croniter
# flask-babel
@@ -373,7 +374,7 @@ sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
sqlglot==26.11.1
sqlglot==26.16.2
# via apache-superset (pyproject.toml)
sqlparse==0.5.3
# via apache-superset (pyproject.toml)
@@ -398,9 +399,8 @@ typing-extensions==4.12.2
# rich
# selenium
# shillelagh
tzdata==2025.1
tzdata==2025.2
# via
# celery
# kombu
# pandas
url-normalize==1.4.3
+13 -13
View File
@@ -72,7 +72,7 @@ cattrs==24.1.2
# via
# -c requirements/base.txt
# requests-cache
celery==5.4.0
celery==5.5.2
# via
# -c requirements/base.txt
# apache-superset
@@ -318,6 +318,7 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -354,7 +355,7 @@ idna==3.10
# email-validator
# requests
# trio
importlib-metadata==8.6.1
importlib-metadata==8.7.0
# via
# -c requirements/base.txt
# apache-superset
@@ -395,7 +396,7 @@ jsonschema-specifications==2024.10.1
# openapi-schema-validator
kiwisolver==1.4.7
# via matplotlib
kombu==5.5.0
kombu==5.5.3
# via
# -c requirements/base.txt
# celery
@@ -405,11 +406,11 @@ korean-lunar-calendar==0.3.1
# holidays
lazy-object-proxy==1.10.0
# via openapi-spec-validator
limits==4.4.1
limits==5.1.0
# via
# -c requirements/base.txt
# flask-limiter
mako==1.3.9
mako==1.3.10
# via
# -c requirements/base.txt
# alembic
@@ -495,7 +496,7 @@ outcome==1.3.0.post0
# via
# -c requirements/base.txt
# trio
packaging==24.2
packaging==25.0
# via
# -c requirements/base.txt
# apache-superset
@@ -565,7 +566,7 @@ prison==0.2.1
# flask-appbuilder
progress==1.6
# via apache-superset
prompt-toolkit==3.0.50
prompt-toolkit==3.0.51
# via
# -c requirements/base.txt
# click-repl
@@ -635,7 +636,7 @@ pyopenssl==25.0.0
# via
# -c requirements/base.txt
# shillelagh
pyparsing==3.2.2
pyparsing==3.2.3
# via
# -c requirements/base.txt
# apache-superset
@@ -668,7 +669,7 @@ python-dateutil==2.9.0.post0
# pyhive
# shillelagh
# trino
python-dotenv==1.0.1
python-dotenv==1.1.0
# via
# -c requirements/base.txt
# apache-superset
@@ -678,7 +679,7 @@ python-geohash==0.8.5
# apache-superset
python-ldap==3.4.4
# via apache-superset
pytz==2025.1
pytz==2025.2
# via
# -c requirements/base.txt
# croniter
@@ -799,7 +800,7 @@ sqlalchemy-utils==0.38.3
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
sqlglot==26.11.1
sqlglot==26.16.2
# via
# -c requirements/base.txt
# apache-superset
@@ -850,10 +851,9 @@ typing-extensions==4.12.2
# rich
# selenium
# shillelagh
tzdata==2025.1
tzdata==2025.2
# via
# -c requirements/base.txt
# celery
# kombu
# pandas
tzlocal==5.2
@@ -56,7 +56,6 @@ describe('Visualization > Big Number with Trendline', () => {
it('should work', () => {
verify(BIG_NUMBER_FORM_DATA);
cy.get('.chart-container .header-line');
cy.get('.chart-container .subheader-line');
cy.get('.chart-container canvas');
});
@@ -66,7 +65,7 @@ describe('Visualization > Big Number with Trendline', () => {
compare_lag: null,
});
cy.get('.chart-container .header-line');
cy.get('.chart-container .subheader-line').should('not.exist');
cy.get('.chart-container .subtitle-line').should('not.exist');
cy.get('.chart-container canvas');
});
@@ -76,7 +75,6 @@ describe('Visualization > Big Number with Trendline', () => {
show_trend_line: false,
});
cy.get('[data-test="chart-container"] .header-line');
cy.get('[data-test="chart-container"] .subheader-line');
cy.get('[data-test="chart-container"] canvas').should('not.exist');
});
});
+3 -3
View File
@@ -26682,9 +26682,9 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -26,6 +26,7 @@ import {
} from '@superset-ui/core';
import { PostProcessingFactory } from './types';
import { getMetricOffsetsMap, isTimeComparison } from './utils';
import { TIME_COMPARISON_SEPARATOR } from './utils/constants';
export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
formData,
@@ -37,50 +38,60 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
);
const { truncate_metric } = formData;
const xAxisLabel = getXAxisLabel(formData);
const isTimeComparisonValue = isTimeComparison(formData, queryObject);
// remove or rename top level of column name(metric name) in the MultiIndex when
// 1) only 1 metric
// 1) at least 1 metric
// 2) dimension exist
// 3) xAxis exist
// 4) time comparison exist, and comparison type is "actual values"
// 5) truncate_metric in form_data and truncate_metric is true
// 4) truncate_metric in form_data and truncate_metric is true
if (
metrics.length === 1 &&
metrics.length > 0 &&
columns.length > 0 &&
xAxisLabel &&
!(
// todo: we should provide an approach to handle derived metrics
(
isTimeComparison(formData, queryObject) &&
[
ComparisonType.Difference,
ComparisonType.Ratio,
ComparisonType.Percentage,
].includes(formData.comparison_type)
)
) &&
truncate_metric !== undefined &&
!!truncate_metric
) {
const renamePairs: [string, string | null][] = [];
if (
// "actual values" will add derived metric.
// we will rename the "metric" from the metricWithOffset label
// for example: "count__1 year ago" => "1 year ago"
isTimeComparison(formData, queryObject) &&
formData.comparison_type === ComparisonType.Values
isTimeComparisonValue
) {
const metricOffsetMap = getMetricOffsetsMap(formData, queryObject);
const timeOffsets = ensureIsArray(formData.time_compare);
[...metricOffsetMap.keys()].forEach(metricWithOffset => {
const offsetLabel = timeOffsets.find(offset =>
metricWithOffset.includes(offset),
);
renamePairs.push([metricWithOffset, offsetLabel]);
});
[...metricOffsetMap.entries()].forEach(
([metricWithOffset, metricOnly]) => {
const offsetLabel = timeOffsets.find(offset =>
metricWithOffset.includes(offset),
);
renamePairs.push([
formData.comparison_type === ComparisonType.Values
? metricWithOffset
: [formData.comparison_type, metricOnly, metricWithOffset].join(
TIME_COMPARISON_SEPARATOR,
),
metrics.length > 1 ? `${metricOnly}, ${offsetLabel}` : offsetLabel,
]);
},
);
}
renamePairs.push([getMetricLabel(metrics[0]), null]);
if (
![
ComparisonType.Difference,
ComparisonType.Percentage,
ComparisonType.Ratio,
].includes(formData.comparison_type) &&
metrics.length === 1
) {
renamePairs.push([getMetricLabel(metrics[0]), null]);
}
if (renamePairs.length === 0) {
return undefined;
}
return {
operation: 'rename',
@@ -43,12 +43,12 @@ const queryObject: QueryObject = {
post_processing: [],
};
test('should skip renameOperator if exists multiple metrics', () => {
test('should skip renameOperator for empty metrics', () => {
expect(
renameOperator(formData, {
...queryObject,
...{
metrics: ['count(*)', 'sum(sales)'],
metrics: [],
},
}),
).toEqual(undefined);
@@ -77,7 +77,23 @@ test('should skip renameOperator if does not exist x_axis and is_timeseries', ()
).toEqual(undefined);
});
test('should skip renameOperator if exists derived metrics', () => {
test('should skip renameOperator if not is_timeseries and multi metrics', () => {
expect(
renameOperator(formData, {
...queryObject,
...{ is_timeseries: false, metrics: ['count(*)', 'sum(val)'] },
}),
).toEqual(undefined);
});
test('should add renameOperator', () => {
expect(renameOperator(formData, queryObject)).toEqual({
operation: 'rename',
options: { columns: { 'count(*)': null }, inplace: true, level: 0 },
});
});
test('should add renameOperator if exists derived metrics', () => {
[
ComparisonType.Difference,
ComparisonType.Ratio,
@@ -99,14 +115,14 @@ test('should skip renameOperator if exists derived metrics', () => {
},
},
),
).toEqual(undefined);
});
});
test('should add renameOperator', () => {
expect(renameOperator(formData, queryObject)).toEqual({
operation: 'rename',
options: { columns: { 'count(*)': null }, inplace: true, level: 0 },
).toEqual({
operation: 'rename',
options: {
columns: { [`${type}__count(*)__count(*)__1 year ago`]: '1 year ago' },
inplace: true,
level: 0,
},
});
});
});
@@ -170,6 +186,61 @@ test('should add renameOperator if exist "actual value" time comparison', () =>
});
});
test('should add renameOperator if derived time comparison exists', () => {
expect(
renameOperator(
{
...formData,
...{
comparison_type: ComparisonType.Ratio,
time_compare: ['1 year ago', '1 year later'],
},
},
queryObject,
),
).toEqual({
operation: 'rename',
options: {
columns: {
'ratio__count(*)__count(*)__1 year ago': '1 year ago',
'ratio__count(*)__count(*)__1 year later': '1 year later',
},
inplace: true,
level: 0,
},
});
});
test('should add renameOperator if multiple metrics exist', () => {
expect(
renameOperator(
{
...formData,
...{
comparison_type: ComparisonType.Values,
time_compare: ['1 year ago'],
},
},
{
...queryObject,
...{
metrics: ['count(*)', 'sum(sales)'],
},
},
),
).toEqual({
operation: 'rename',
options: {
columns: {
'count(*)__1 year ago': 'count(*), 1 year ago',
'sum(sales)__1 year ago': 'sum(sales), 1 year ago',
},
inplace: true,
level: 0,
},
});
});
test('should remove renameOperator', () => {
expect(
renameOperator(
@@ -35,7 +35,8 @@ export type Locale =
| 'pt'
| 'pt_BR'
| 'ru'
| 'zh'; // supported locales in Superset
| 'zh'
| 'zh_TW'; // supported locales in Superset
/**
* Language pack provided to `jed`.
File diff suppressed because one or more lines are too long
@@ -103,6 +103,7 @@ import iran from './countries/iran.geojson';
import israel from './countries/israel.geojson';
import italy from './countries/italy.geojson';
import italy_regions from './countries/italy_regions.geojson';
import ivory_coast from './countries/ivory_coast.geojson';
import japan from './countries/japan.geojson';
import jordan from './countries/jordan.geojson';
import kazakhstan from './countries/kazakhstan.geojson';
@@ -160,6 +161,7 @@ import philippines_regions from './countries/philippines_regions.geojson';
import poland from './countries/poland.geojson';
import portugal from './countries/portugal.geojson';
import qatar from './countries/qatar.geojson';
import republic_of_serbia from './countries/republic_of_serbia.geojson';
import romania from './countries/romania.geojson';
import russia from './countries/russia.geojson';
import rwanda from './countries/rwanda.geojson';
@@ -304,6 +306,7 @@ export const countries = {
israel,
italy,
italy_regions,
ivory_coast,
japan,
jordan,
kazakhstan,
@@ -361,6 +364,7 @@ export const countries = {
poland,
portugal,
qatar,
republic_of_serbia,
romania,
russia,
rwanda,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -77,7 +77,7 @@ export function getLayer(
getTargetColor: (d: any) =>
d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a],
id: `path-layer-${fd.slice_id}` as const,
strokeWidth: fd.stroke_width ? fd.stroke_width : 3,
getWidth: fd.stroke_width ? fd.stroke_width : 3,
...commonLayerProps(fd, setTooltip, setTooltipContent(fd)),
});
}
@@ -0,0 +1,97 @@
/**
* 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.
*/
import { SqlaFormData } from '@superset-ui/core';
import * as ChartControls from '@superset-ui/chart-controls';
import controlPanel from './controlPanel';
const { __mockShiftMetric } = ChartControls as any;
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Numeric: 'numeric' },
SMART_DATE_ID: 'SMART_DATE_ID',
t: (str: string) => str,
}));
jest.mock('@superset-ui/chart-controls', () => {
// Define the mock function inside the factory
const mockShiftMetric = jest.fn(() => 'shiftedMetric');
return {
ControlPanelConfig: {},
D3_FORMAT_DOCS: 'Format docs',
D3_TIME_FORMAT_OPTIONS: [['', 'default']],
getStandardizedControls: () => ({
shiftMetric: mockShiftMetric,
}),
// Optional export to let tests access the mock
__mockShiftMetric: mockShiftMetric,
};
});
describe('BigNumber Total Control Panel Config', () => {
it('should have the required control panel sections', () => {
expect(controlPanel).toHaveProperty('controlPanelSections');
const sections = controlPanel.controlPanelSections;
expect(Array.isArray(sections)).toBe(true);
expect(sections.length).toBe(2);
// First section should have label 'Query' and contain rows with metric and adhoc_filters
expect(sections[0]!.label).toBe('Query');
expect(Array.isArray(sections[0]!.controlSetRows)).toBe(true);
expect(sections[0]!.controlSetRows[0]).toEqual(['metric']);
expect(sections[0]!.controlSetRows[1]).toEqual(['adhoc_filters']);
// Second section should contain a control named subtitle
const secondSectionRow = sections[1]!.controlSetRows[1];
expect(secondSectionRow[0]).toHaveProperty('name', 'subtitle');
// Second section should include controls for time_format and conditional_formatting
const thirdSection = sections[1]!.controlSetRows;
// Check time_format control exists in one of the rows
const timeFormatRow = thirdSection.find(row =>
row.some((control: any) => control.name === 'time_format'),
);
expect(timeFormatRow).toBeTruthy();
// Check conditional_formatting control exists in one of the rows
const conditionalFormattingRow = thirdSection.find(row =>
row.some((control: any) => control.name === 'conditional_formatting'),
);
expect(conditionalFormattingRow).toBeTruthy();
});
it('should have y_axis_format override with correct label', () => {
expect(controlPanel).toHaveProperty('controlOverrides');
expect(controlPanel.controlOverrides).toHaveProperty('y_axis_format');
expect(controlPanel.controlOverrides!.y_axis_format!.label).toBe(
'Number format',
);
});
it('should override formData metric using getStandardizedControls', () => {
const dummyFormData = { someProp: 'test' } as unknown as SqlaFormData;
const newFormData = controlPanel.formDataOverrides!(dummyFormData);
// The original properties are spread correctly.
expect(newFormData.someProp).toBe('test');
// The metric property should be replaced by the output of shiftMetric.
expect(newFormData.metric).toBe('shiftedMetric');
// Ensure that the mockShiftMetric function was called.
expect(__mockShiftMetric).toHaveBeenCalled();
});
});
@@ -0,0 +1,252 @@
/**
* 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.
*/
import { GenericDataType } from '@superset-ui/core';
import { getColorFormatters } from '@superset-ui/chart-controls';
import { BigNumberTotalChartProps } from '../types';
import transformProps from './transformProps';
jest.mock('@superset-ui/chart-controls', () => ({
getColorFormatters: jest.fn(),
}));
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Temporal: 2, String: 1 },
getMetricLabel: jest.fn(metric => metric),
extractTimegrain: jest.fn(() => 'P1D'),
getValueFormatter: jest.fn(() => (v: any) => `$${v}`),
}));
jest.mock('../utils', () => ({
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
parseMetricValue: jest.fn(val => Number(val)),
}));
describe('BigNumberTotal transformProps', () => {
const onContextMenu = jest.fn();
const baseFormData = {
headerFontSize: 20,
metric: 'value',
subheader: 'sub header text',
subheaderFontSize: 14,
forceTimestampFormatting: false,
timeFormat: 'YYYY-MM-DD',
yAxisFormat: 'SMART_NUMBER',
conditionalFormatting: [{ color: 'red', op: '>', value: 0 }],
currencyFormat: { symbol: '$', symbolPosition: 'prefix' },
};
const baseDatasource = {
currencyFormats: { value: '$0,0.00' },
columnFormats: { value: '$0,0.00' },
metrics: [{ metric_name: 'value', d3format: '.2f' }],
};
const baseHooks = { onContextMenu };
const baseRawFormData = { dummy: 'raw' };
it('should return null bigNumber when no data is provided', () => {
const chartProps = {
width: 400,
height: 300,
queriesData: [{ data: [], coltypes: [] }],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBeNull();
expect(result.width).toBe(400);
expect(result.height).toBe(300);
expect(result.subtitle).toBe(baseFormData.subheader);
expect(result.onContextMenu).toBe(onContextMenu);
expect(result.refs).toEqual({});
// headerFormatter should be set even if there's no data
expect(typeof result.headerFormatter).toBe('function');
// colorThresholdFormatters fallback to empty array when getColorFormatters returns falsy
expect(result.colorThresholdFormatters).toEqual([]);
});
it('should convert subheader to subtitle', () => {
const chartProps = {
width: 400,
height: 300,
queriesData: [{ data: [], coltypes: [] }],
formData: { ...baseFormData, subheader: 'test' },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.subtitle).toBe('test');
});
const baseChartProps = {
width: 400,
height: 300,
queriesData: [{ data: [], coltypes: [] }],
rawFormData: { dummy: 'raw' },
hooks: { onContextMenu: jest.fn() },
datasource: {
currencyFormats: { value: '$0,0.00' },
columnFormats: { value: '$0,0.00' },
metrics: [{ metric_name: 'value', d3format: '.2f' }],
},
};
it('uses subtitle font size when subtitle is provided', () => {
const result = transformProps({
...baseChartProps,
formData: {
subtitle: 'Subtitle wins',
subheader: 'Fallback subheader',
subtitleFontSize: 0.4,
subheaderFontSize: 0.99,
metric: 'value',
headerFontSize: 0.3,
yAxisFormat: 'SMART_NUMBER',
timeFormat: 'smart_date',
},
} as unknown as BigNumberTotalChartProps);
expect(result.subtitle).toBe('Subtitle wins');
expect(result.subtitleFontSize).toBe(0.4);
});
it('should compute bigNumber using parseMetricValue when data exists', () => {
const chartProps = {
width: 500,
height: 400,
queriesData: [
{ data: [{ value: '456' }], coltypes: [GenericDataType.String] },
],
formData: { ...baseFormData, forceTimestampFormatting: false },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
sortBy: 'value',
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
// parseMetricValue converts '456' to number 456 by our mock
expect(result.bigNumber).toEqual(456);
});
it('should use formatTime as headerFormatter for Temporal or String types or forced formatting', () => {
// Case 1: Temporal type
const chartPropsTemporal = {
width: 600,
height: 450,
queriesData: [
{ data: [{ value: '789' }], coltypes: [GenericDataType.Temporal] },
],
formData: { ...baseFormData, forceTimestampFormatting: false },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const resultTemporal = transformProps(
chartPropsTemporal as unknown as BigNumberTotalChartProps,
);
expect(resultTemporal.headerFormatter(5)).toBe('5pm');
// Case 2: String type regardless of forcing formatting
const chartPropsString = {
width: 600,
height: 450,
queriesData: [
{ data: [{ value: '789' }], coltypes: [GenericDataType.String] },
],
formData: { ...baseFormData, forceTimestampFormatting: false },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const resultString = transformProps(
chartPropsString as unknown as BigNumberTotalChartProps,
);
expect(resultString.headerFormatter(5)).toBe('5pm');
// Case 3: Forced timestamp formatting
const chartPropsForced = {
width: 600,
height: 450,
queriesData: [{ data: [{ value: '789' }], coltypes: [0] }], // non-temporal/non-string
formData: { ...baseFormData, forceTimestampFormatting: true },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const resultForced = transformProps(
chartPropsForced as unknown as BigNumberTotalChartProps,
);
expect(resultForced.headerFormatter(5)).toBe('5pm');
});
it('should use numberFormatter as headerFormatter when not Temporal/String and no forced formatting', () => {
const chartProps = {
width: 700,
height: 500,
queriesData: [{ data: [{ value: '321' }], coltypes: [0] }], // non-temporal/non-string
formData: { ...baseFormData, forceTimestampFormatting: false },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.headerFormatter(500)).toBe('$500');
});
it('should propagate colorThresholdFormatters from getColorFormatters', () => {
// Override the getColorFormatters mock to return specific value
const mockFormatters = [{ formatter: 'red' }];
(getColorFormatters as jest.Mock).mockReturnValueOnce(mockFormatters);
const chartProps = {
width: 800,
height: 600,
queriesData: [
{ data: [{ value: '100' }], coltypes: [GenericDataType.Temporal] },
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.colorThresholdFormatters).toEqual(mockFormatters);
});
});
@@ -47,19 +47,24 @@ export default function transformProps(
const {
headerFontSize,
metric = 'value',
subtitle = '',
subtitle,
subtitleFontSize,
forceTimestampFormatting,
timeFormat,
yAxisFormat,
conditionalFormatting,
currencyFormat,
subheader,
subheaderFontSize,
} = formData;
const refs: Refs = {};
const { data = [], coltypes = [] } = queriesData[0];
const granularity = extractTimegrain(rawFormData as QueryFormData);
const metricName = getMetricLabel(metric);
const formattedSubtitle = subtitle;
const formattedSubtitle = subtitle?.trim() ? subtitle : subheader || '';
const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? 1)
: (subheaderFontSize ?? 1);
const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
@@ -105,10 +110,9 @@ export default function transformProps(
bigNumber,
headerFormatter,
headerFontSize,
subtitleFontSize,
subheaderFontSize,
subtitleFontSize: formattedSubtitleFontSize,
subtitle: formattedSubtitle,
subheader: '',
subheaderFontSize: subtitleFontSize,
onContextMenu,
refs,
colorThresholdFormatters,
@@ -188,31 +188,26 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
);
}
renderSubheader(maxHeight: number) {
const { bigNumber, subheader, width, bigNumberFallback } = this.props;
rendermetricComparisonSummary(maxHeight: number) {
const { subheader, width } = this.props;
let fontSize = 0;
const NO_DATA_OR_HASNT_LANDED = t(
'No data after filtering or data is NULL for the latest time record',
);
const NO_DATA = t(
'Try applying different filters or ensuring your datasource has data',
);
let text = subheader;
if (bigNumber === null) {
text = bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED;
}
const text = subheader;
if (text) {
const container = this.createTemporaryContainer();
document.body.append(container);
fontSize = computeMaxFontSize({
text,
maxWidth: width * 0.9, // max width reduced
maxHeight,
className: 'subheader-line',
container,
});
container.remove();
try {
fontSize = computeMaxFontSize({
text,
maxWidth: width * 0.9,
maxHeight,
className: 'subheader-line',
container,
});
} finally {
container.remove();
}
return (
<div
@@ -230,34 +225,46 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
}
renderSubtitle(maxHeight: number) {
const { subtitle, width } = this.props;
const { subtitle, width, bigNumber, bigNumberFallback } = this.props;
let fontSize = 0;
if (subtitle) {
const NO_DATA_OR_HASNT_LANDED = t(
'No data after filtering or data is NULL for the latest time record',
);
const NO_DATA = t(
'Try applying different filters or ensuring your datasource has data',
);
let text = subtitle;
if (bigNumber === null) {
text =
subtitle || (bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED);
}
if (text) {
const container = this.createTemporaryContainer();
document.body.append(container);
try {
fontSize = computeMaxFontSize({
text: subtitle,
maxWidth: width * 0.9,
maxHeight,
className: 'subtitle-line',
container,
});
} finally {
container.remove();
}
fontSize = computeMaxFontSize({
text,
maxWidth: width * 0.9,
maxHeight,
className: 'subtitle-line',
container,
});
container.remove();
return (
<div
className="subtitle-line"
style={{
fontSize,
height: maxHeight,
}}
>
{subtitle}
</div>
<>
<div
className="subtitle-line subheader-line"
style={{
fontSize: `${fontSize}px`,
height: maxHeight,
}}
>
{text}
</div>
</>
);
}
return null;
@@ -315,8 +322,8 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
height,
kickerFontSize,
headerFontSize,
subheaderFontSize,
subtitleFontSize,
subheaderFontSize,
} = this.props;
const className = this.getClassName();
@@ -336,7 +343,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
{this.renderHeader(
Math.ceil(headerFontSize * (1 - PROPORTION.TRENDLINE) * height),
)}
{this.renderSubheader(
{this.rendermetricComparisonSummary(
Math.ceil(
subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height,
),
@@ -355,7 +362,9 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
{this.renderFallbackWarning()}
{this.renderKicker((kickerFontSize || 0) * height)}
{this.renderHeader(Math.ceil(headerFontSize * height))}
{this.renderSubheader(Math.ceil(subheaderFontSize * height))}
{this.rendermetricComparisonSummary(
Math.ceil(subheaderFontSize * height),
)}
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
</div>
);
@@ -407,12 +416,12 @@ export default styled(BigNumberVis)`
.subheader-line {
line-height: 1em;
padding-bottom: 0.3em;
padding-bottom: 0;
}
.subtitle-line {
line-height: 1em;
padding-top: 0.3em;
padding-bottom: 0;
}
&.is-fallback-value {
@@ -0,0 +1,196 @@
/**
* 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.
*/
import { GenericDataType } from '@superset-ui/core';
import transformProps from './transformProps';
import { BigNumberWithTrendlineChartProps, BigNumberDatum } from '../types';
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Temporal: 2, String: 1 },
extractTimegrain: jest.fn(() => 'P1D'),
getMetricLabel: jest.fn(metric => metric),
getXAxisLabel: jest.fn(() => '__timestamp'),
getValueFormatter: jest.fn(() => ({
format: (v: number) => `$${v}`,
})),
getNumberFormatter: jest.fn(() => (v: number) => `${(v * 100).toFixed(1)}%`),
t: jest.fn(v => v),
tooltipHtml: jest.fn(() => '<div>tooltip</div>'),
NumberFormats: {
PERCENT_SIGNED_1_POINT: '.1%',
},
}));
jest.mock('../utils', () => ({
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
parseMetricValue: jest.fn(val => Number(val)),
}));
jest.mock('../../utils/tooltip', () => ({
getDefaultTooltip: jest.fn(() => ({})),
}));
describe('BigNumberWithTrendline transformProps', () => {
const onContextMenu = jest.fn();
const baseFormData = {
headerFontSize: 20,
metric: 'value',
subtitle: 'subtitle message',
subtitleFontSize: 14,
forceTimestampFormatting: false,
timeFormat: 'YYYY-MM-DD',
yAxisFormat: 'SMART_NUMBER',
compareLag: 1,
compareSuffix: 'WoW',
colorPicker: { r: 0, g: 0, b: 0 },
currencyFormat: { symbol: '$', symbolPosition: 'prefix' },
};
const baseDatasource = {
currencyFormats: { value: '$0,0.00' },
columnFormats: { value: '$0,0.00' },
metrics: [{ metric_name: 'value', d3format: '.2f' }],
};
const baseHooks = { onContextMenu };
const baseRawFormData = { dummy: 'raw' };
it('should return null bigNumber when no data is provided', () => {
const chartProps = {
width: 400,
height: 300,
queriesData: [{ data: [] as unknown as BigNumberDatum[], coltypes: [] }],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.bigNumber).toBeNull();
expect(result.subtitle).toBe('subtitle message');
});
it('should calculate subheader as percent change with suffix', () => {
const chartProps = {
width: 500,
height: 400,
queriesData: [
{
data: [
{ __timestamp: 2, value: 110 },
{ __timestamp: 1, value: 100 },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: ['TEMPORAL', 'NUMERIC'],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.subheader).toBe('10.0% WoW');
});
it('should compute bigNumber from parseMetricValue', () => {
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [
{ __timestamp: 2, value: '456' },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [GenericDataType.Temporal, GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.bigNumber).toEqual(456);
});
it('should use formatTime as headerFormatter for Temporal/String or forced', () => {
const formData = { ...baseFormData, forceTimestampFormatting: true };
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [
{ __timestamp: 2, value: '123' },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [0, GenericDataType.String],
},
],
formData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.headerFormatter(5)).toBe('5pm');
});
it('should use numberFormatter when not Temporal/String and not forced', () => {
const formData = { ...baseFormData, forceTimestampFormatting: false };
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [{ __timestamp: 2, value: 500 }] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [0, 0],
},
],
formData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.headerFormatter.format(500)).toBe('$500');
});
});
@@ -77,7 +77,7 @@ export type BigNumberVizProps = {
formatTime?: TimeFormatter;
headerFontSize: number;
kickerFontSize?: number;
subheader: string;
subheader?: string;
subtitle: string;
subheaderFontSize: number;
subtitleFontSize: number;
@@ -150,7 +150,7 @@ export default function transformProps(
data: data.map(row =>
colnames.map(col => {
const value = row[col];
if (!value) {
if (value === null || value === undefined) {
return NULL_STRING;
}
if (typeof value === 'boolean' || typeof value === 'bigint') {
@@ -84,7 +84,7 @@ export default function transformProps(
.filter(key => !groupbySet.has(key))
.map(key => {
const array = key.split(' - ').map(value => parseFloat(value));
return `${xAxisFormatter(array[0])} '-' ${xAxisFormatter(array[1])}`;
return `${xAxisFormatter(array[0])} - ${xAxisFormatter(array[1])}`;
});
const barSeries: BarSeriesOption[] = data.map(datum => {
const seriesName =
@@ -36,6 +36,21 @@ const defaultProps = {
subtitle: 'Test subtitle',
};
const missingExtraProps = {
...defaultProps,
error: {
error_type: ErrorTypeEnum.INVALID_SQL_ERROR,
message: 'SQLStatement should have exactly one statement',
level: 'error' as ErrorLevel,
extra: {
sql: null,
line: null,
column: null,
engine: null,
},
},
};
const renderComponent = (overrides = {}) =>
render(<InvalidSQLErrorMessage {...defaultProps} {...overrides} />);
@@ -60,6 +75,12 @@ describe('InvalidSQLErrorMessage', () => {
unmount();
});
it('renders the error message with the empty extra properties', () => {
const { getByText } = renderComponent(missingExtraProps);
expect(getByText('Unable to parse SQL')).toBeInTheDocument();
expect(getByText(missingExtraProps.error.message)).toBeInTheDocument();
});
it('displays the SQL error line and column indicator', async () => {
const { getByText, container, unmount } = renderComponent();
@@ -36,20 +36,22 @@ function InvalidSQLErrorMessage({
source,
subtitle,
}: ErrorMessageComponentProps<SupersetParseErrorExtra>) {
const { extra, level } = error;
const { extra, level, message } = error;
const { sql, line, column } = extra;
const lines = sql.split('\n');
const lines = sql?.split('\n');
let errorLine;
if (line !== null) errorLine = lines[line - 1];
else if (lines.length > 0) {
if (line !== null && Number.isInteger(line)) errorLine = lines[line - 1];
else if (lines?.length > 0) {
errorLine = lines[0];
}
const body = errorLine && (
const body = errorLine ? (
<>
<pre>{errorLine}</pre>
{column !== null && <pre>{' '.repeat(column - 1)}^</pre>}
</>
) : (
message
);
return (
<ErrorAlert
@@ -150,6 +150,9 @@ const Chart = props => {
const emitCrossFilters = useSelector(
state => !!state.dashboardInfo.crossFiltersEnabled,
);
const maxRows = useSelector(
state => state.dashboardInfo.common.conf.SQL_MAX_ROW,
);
const datasource = useSelector(
state =>
(chart &&
@@ -360,9 +363,7 @@ const Chart = props => {
is_cached: props.isCached,
});
exportChart({
formData: isFullCSV
? { ...formData, row_limit: props.maxRows }
: formData,
formData: isFullCSV ? { ...formData, row_limit: maxRows } : formData,
resultType: isPivot ? 'post_processed' : 'full',
resultFormat: format,
force: true,
@@ -34,7 +34,7 @@ const props = {
height: 100,
updateSliceName() {},
// from redux
maxRows: 666,
maxRows: 500, // will be overwritten with SQL_MAX_ROW from conf
formData: chartQueries[queryId].form_data,
datasource: mockDatasource[sliceEntities.slices[queryId].datasource],
sliceName: sliceEntities.slices[queryId].slice_name,
@@ -78,7 +78,7 @@ const defaultState = {
superset_can_explore: false,
superset_can_share: false,
superset_can_csv: false,
common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0 } },
common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0, SQL_MAX_ROW: 666 } },
},
};
@@ -46,6 +46,7 @@ export const FilterTitle = styled.div`
}
}
&.errored div, &.errored .warning {
align-items: center;
color: ${theme.colors.error.base};
}
`}
@@ -120,7 +121,7 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, Props>(
{isRemoved ? t('(Removed)') : getFilterTitle(id)}
</div>
{!removedFilters[id] && isErrored && (
<StyledWarning className="warning" />
<StyledWarning className="warning" iconSize="s" />
)}
{isRemoved && (
<span
@@ -99,6 +99,7 @@ export function ColumnSelect({
'columns.column_name',
'columns.is_dttm',
'columns.type_generic',
'columns.filterable',
],
})}`,
})
@@ -20,11 +20,38 @@ import { Filter, NativeFilterType } from '@superset-ui/core';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import { FormInstance } from 'src/components';
import getControlItemsMap, { ControlItemsProps } from './getControlItemsMap';
import { getControlItems, setNativeFilterFieldValues } from './utils';
import {
getControlItems,
setNativeFilterFieldValues,
doesColumnMatchFilterType,
} from './utils';
jest.mock('./utils', () => ({
getControlItems: jest.fn(),
setNativeFilterFieldValues: jest.fn(),
doesColumnMatchFilterType: jest.fn(),
}));
// Mock ColumnSelect to test filterValues logic
jest.mock('./ColumnSelect', () => ({
ColumnSelect: ({
filterValues,
}: {
filterValues: (column: any) => boolean;
}) => {
const columns = [
{ name: 'col1', filterable: true },
{ name: 'col2', filterable: false },
{ name: 'col3', filterable: true },
];
return (
<>
{columns.filter(filterValues).map(column => (
<div key={column.name}>{column.name}</div>
))}
</>
);
},
}));
const formMock: FormInstance = {
@@ -62,7 +89,7 @@ const filterMock: Filter = {
description: '',
};
const createProps: () => ControlItemsProps = () => ({
const createProps = (): ControlItemsProps => ({
expanded: false,
datasetId: 1,
disabled: false,
@@ -179,3 +206,42 @@ test('Clicking on checkbox when resetConfig:false', () => {
expect(props.forceUpdate).toHaveBeenCalled();
expect(setNativeFilterFieldValues).not.toHaveBeenCalled();
});
describe('ColumnSelect filterValues behavior', () => {
beforeEach(() => {
(getControlItems as jest.Mock).mockReturnValue([
{
name: 'groupby',
config: { label: 'Column', multiple: false, required: false },
},
]);
});
test('only renders filterable columns when doesColumnMatchFilterType returns true', () => {
(doesColumnMatchFilterType as jest.Mock).mockReturnValue(true);
const props = {
...createProps(),
formFilter: { filterType: 'filterType' },
};
const element = getControlItemsMap(props).mainControlItems.groupby
.element as React.ReactElement;
render(element);
expect(screen.getByText('col1')).toBeInTheDocument();
expect(screen.getByText('col3')).toBeInTheDocument();
expect(screen.queryByText('col2')).not.toBeInTheDocument();
});
test('renders no columns when doesColumnMatchFilterType returns false', () => {
(doesColumnMatchFilterType as jest.Mock).mockReturnValue(false);
const props = {
...createProps(),
formFilter: { filterType: 'filterType' },
};
const element = getControlItemsMap(props).mainControlItems.groupby
.element as React.ReactElement;
render(element);
expect(screen.queryByText('col1')).not.toBeInTheDocument();
expect(screen.queryByText('col3')).not.toBeInTheDocument();
expect(screen.queryByText('col2')).not.toBeInTheDocument();
});
});
@@ -131,7 +131,10 @@ export default function getControlItemsMap({
filterId={filterId}
datasetId={datasetId}
filterValues={column =>
doesColumnMatchFilterType(formFilter?.filterType || '', column)
doesColumnMatchFilterType(
formFilter?.filterType || '',
column,
) && column.filterable
}
onChange={() => {
// We need reset default value when column changed
@@ -734,6 +734,17 @@ const retainQueryModeRequirements = hiddenFormData =>
key => !QUERY_MODE_REQUISITES.has(key),
);
function patchBigNumberTotalFormData(form_data, slice) {
if (
form_data.viz_type === 'big_number_total' &&
!form_data.subtitle &&
slice?.form_data?.subheader
) {
return { ...form_data, subtitle: slice.form_data.subheader };
}
return form_data;
}
function mapStateToProps(state) {
const {
explore,
@@ -768,6 +779,25 @@ function mapStateToProps(state) {
dashboardId = undefined;
}
if (
form_data.viz_type === 'big_number_total' &&
slice?.form_data?.subheader &&
(!controls.subtitle?.value || controls.subtitle.value === '')
) {
controls.subtitle = {
...controls.subtitle,
value: slice.form_data.subheader,
};
if (slice?.form_data?.subheader_font_size) {
controls.subtitle_font_size = {
...controls.subtitle_font_size,
value: slice.form_data.subheader_font_size,
};
}
}
const patchedFormData = patchBigNumberTotalFormData(form_data, slice);
return {
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
datasource,
@@ -789,7 +819,7 @@ function mapStateToProps(state) {
slice,
sliceName: explore.sliceName ?? slice?.slice_name ?? null,
triggerRender: explore.triggerRender,
form_data,
form_data: patchedFormData,
table_name: datasource.table_name,
vizType: form_data.viz_type,
standalone: !!explore.standalone,
@@ -74,7 +74,6 @@ const config: ControlPanelConfig = {
type: 'CheckboxControl',
label: t('Allow creation of new values'),
default: creatable,
resetConfig: true,
affectsDataMask: true,
renderTrigger: true,
},
@@ -69,7 +69,11 @@ def import_database(
# Before it gets removed in import_from_dict
ssh_tunnel_config = config.pop("ssh_tunnel", None)
# set SQLAlchemy URI via `set_sqlalchemy_uri` so that the password gets masked
sqlalchemy_uri = config.pop("sqlalchemy_uri")
database: Database = Database.import_from_dict(config, recursive=False)
database.set_sqlalchemy_uri(sqlalchemy_uri)
if database.id is None:
db.session.flush()
+14 -3
View File
@@ -80,9 +80,19 @@ class UpdateDatabaseCommand(BaseCommand):
# existing personal tokens.
self._handle_oauth2()
# build new DB
# Some DBs require running a query to get the default catalog.
# In these cases, if the current connection is broken then
# `get_default_catalog` would raise an exception. We need to
# gracefully handle that so that the connection can be fixed.
original_database_name = self._model.database_name
original_catalog = self._model.get_default_catalog()
force_update: bool = False
try:
original_catalog = self._model.get_default_catalog()
except Exception:
original_catalog = None
force_update = True
# build new DB
database = DatabaseDAO.update(self._model, self._properties)
database.set_sqlalchemy_uri(database.sqlalchemy_uri)
ssh_tunnel = self._handle_ssh_tunnel(database)
@@ -92,7 +102,8 @@ class UpdateDatabaseCommand(BaseCommand):
# configured with multi-catalog support; if it was enabled or is enabled in the
# update we don't update the assets
if (
new_catalog != original_catalog
force_update
or new_catalog != original_catalog
and not self._model.allow_multi_catalog
and not database.allow_multi_catalog
):
+25 -12
View File
@@ -15,14 +15,21 @@
# specific language governing permissions and limitations
# under the License.
import logging
from functools import partial
from typing import Any
from sqlalchemy.exc import SQLAlchemyError
from superset import db
from superset.commands.sql_lab.permalink.base import BaseSqlLabPermalinkCommand
from superset.daos.key_value import KeyValueDAO
from superset.key_value.exceptions import KeyValueCodecEncodeException
from superset.key_value.exceptions import (
KeyValueCodecEncodeException,
KeyValueCreateFailedError,
)
from superset.key_value.utils import encode_permalink_key
from superset.sqllab.permalink.exceptions import SqlLabPermalinkCreateFailedError
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -31,19 +38,25 @@ class CreateSqlLabPermalinkCommand(BaseSqlLabPermalinkCommand):
def __init__(self, state: dict[str, Any]):
self._properties = state.copy()
@transaction(
on_error=partial(
on_error,
catches=(
KeyValueCodecEncodeException,
KeyValueCreateFailedError,
SQLAlchemyError,
),
reraise=SqlLabPermalinkCreateFailedError,
),
)
def run(self) -> str:
self.validate()
try:
entry = KeyValueDAO.create_entry(
self.resource, self._properties, self.codec
)
db.session.flush()
key = entry.id
if key is None:
raise SqlLabPermalinkCreateFailedError("Unexpected missing key id")
return encode_permalink_key(key=key, salt=self.salt)
except KeyValueCodecEncodeException as ex:
raise SqlLabPermalinkCreateFailedError(str(ex)) from ex
entry = KeyValueDAO.create_entry(self.resource, self._properties, self.codec)
db.session.flush()
key = entry.id
if key is None:
raise SqlLabPermalinkCreateFailedError("Unexpected missing key id")
return encode_permalink_key(key=key, salt=self.salt)
def validate(self) -> None:
pass
+6
View File
@@ -440,6 +440,8 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec):
"""
Return the default catalog.
It's optionally specified in `connect_args.catalog`. If not:
The default behavior for Databricks is confusing. When Unity Catalog is not
enabled we have (the DB engine spec hasn't been tested with it enabled):
@@ -451,6 +453,10 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec):
To handle permissions correctly we use the result of `SHOW CATALOGS` when a
single catalog is returned.
"""
connect_args = cls.get_extra_params(database)["engine_params"]["connect_args"]
if default_catalog := connect_args.get("catalog"):
return default_catalog
with database.get_sqla_engine() as engine:
catalogs = {catalog for (catalog,) in engine.execute("SHOW CATALOGS")}
if len(catalogs) == 1:
+2 -2
View File
@@ -303,7 +303,7 @@ def add_columns(table_name: str, *columns: Column) -> None:
"""
Adds new columns to an existing database table.
If a column already exist, it logs an informational message and skips the adding process.
If a column already exist, or the table doesn't exist, it logs an informational message and skips the adding process.
Otherwise, it proceeds to add the new column to the table.
The operation is performed using Alembic's batch_alter_table.
@@ -333,7 +333,7 @@ def drop_columns(table_name: str, *columns: str) -> None:
"""
Drops specified columns from an existing database table.
If a column does not exist, it logs an informational message and skips the dropping process.
If a column or table does not exist, it logs an informational message and skips the dropping process.
Otherwise, it proceeds to remove the column from the table.
The operation is performed using Alembic's batch_alter_table.
@@ -22,19 +22,20 @@ Create Date: 2022-04-05 13:27:06.028908
"""
from alembic import op
from superset.migrations.shared.utils import create_index, drop_index
# revision identifiers, used by Alembic.
revision = "cdcf3d64daf4"
down_revision = "7fb8bca906d2"
from alembic import op # noqa: E402
def upgrade():
op.create_index(
op.f("ix_logs_user_id_dttm"), "logs", ["user_id", "dttm"], unique=False
create_index(
"logs", op.f("ix_logs_user_id_dttm"), ["user_id", "dttm"], unique=False
)
def downgrade():
op.drop_index(op.f("ix_logs_user_id_dttm"), table_name="logs")
drop_index(index_name=op.f("ix_logs_user_id_dttm"), table_name="logs")
@@ -22,21 +22,20 @@ Create Date: 2023-06-21 14:02:08.200955
"""
import sqlalchemy as sa
from superset.migrations.shared.utils import add_columns, drop_columns
# revision identifiers, used by Alembic.
revision = "90139bf715e4"
down_revision = "83e1abbe777f"
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
def upgrade():
op.add_column("metrics", sa.Column("currency", sa.String(128), nullable=True))
op.add_column("sql_metrics", sa.Column("currency", sa.String(128), nullable=True))
add_columns("metrics", sa.Column("currency", sa.String(128), nullable=True))
add_columns("sql_metrics", sa.Column("currency", sa.String(128), nullable=True))
def downgrade():
with op.batch_alter_table("sql_metrics") as batch_op_sql_metrics:
batch_op_sql_metrics.drop_column("currency")
with op.batch_alter_table("metrics") as batch_op_metrics:
batch_op_metrics.drop_column("currency")
drop_columns("sql_metrics", "currency")
drop_columns("metrics", "currency")
@@ -26,37 +26,41 @@ Create Date: 2024-01-05 16:20:31.598995
revision = "65a167d4c62e"
down_revision = "06dd9ff00fe8"
from alembic import op # noqa: E402
from superset.migrations.shared.utils import create_index, drop_index # noqa: E402
def upgrade():
op.create_index(
"ix_report_execution_log_report_schedule_id",
create_index(
"report_execution_log",
"ix_report_execution_log_report_schedule_id",
["report_schedule_id"],
unique=False,
)
op.create_index(
"ix_report_execution_log_start_dttm",
create_index(
"report_execution_log",
"ix_report_execution_log_start_dttm",
["start_dttm"],
unique=False,
)
op.create_index(
"ix_report_recipient_report_schedule_id",
create_index(
"report_recipient",
"ix_report_recipient_report_schedule_id",
["report_schedule_id"],
unique=False,
)
def downgrade():
op.drop_index(
"ix_report_recipient_report_schedule_id", table_name="report_recipient"
drop_index(
index_name="ix_report_recipient_report_schedule_id",
table_name="report_recipient",
)
op.drop_index(
"ix_report_execution_log_start_dttm", table_name="report_execution_log"
drop_index(
index_name="ix_report_execution_log_start_dttm",
table_name="report_execution_log",
)
op.drop_index(
"ix_report_execution_log_report_schedule_id", table_name="report_execution_log"
drop_index(
index_name="ix_report_execution_log_report_schedule_id",
table_name="report_execution_log",
)
@@ -27,6 +27,7 @@ from alembic import op
from sqlalchemy_utils import EncryptedType
from superset.migrations.shared.utils import (
create_index,
create_table,
drop_fks_for_table,
)
@@ -77,9 +78,9 @@ def upgrade():
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_user_id_database_id",
create_index(
"database_user_oauth2_tokens",
"idx_user_id_database_id",
["user_id", "database_id"],
)
@@ -22,28 +22,26 @@ Create Date: 2024-05-02 13:40:23.126659
"""
from alembic import op
from superset.migrations.shared.utils import create_index, drop_index
# revision identifiers, used by Alembic.
revision = "3dfd0e78650e"
down_revision = "5f57af97bc3f"
from alembic import op # noqa: E402
from superset.migrations.shared.utils import table_has_index # noqa: E402
table = "query"
index = "ix_sql_editor_id"
def upgrade():
if not table_has_index(table, index):
op.create_index(
op.f(index),
table,
["sql_editor_id"],
unique=False,
)
create_index(
table,
op.f(index),
["sql_editor_id"],
unique=False,
)
def downgrade():
if table_has_index(table, index):
op.drop_index(op.f(index), table_name=table)
drop_index(index_name=op.f(index), table_name=table)
+40
View File
@@ -32,6 +32,7 @@ from deprecation import deprecated
from sqlglot import exp
from sqlglot.dialects.dialect import Dialect, Dialects
from sqlglot.errors import ParseError
from sqlglot.expressions import Func
from sqlglot.optimizer.pushdown_predicates import pushdown_predicates
from sqlglot.optimizer.scope import Scope, ScopeType, traverse_scope
@@ -453,6 +454,23 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
return SQLStatement(sql, self.engine, optimized)
def check_functions_present(self, functions: set[str]) -> bool:
"""
Check if any of the given functions are present in the script.
:param functions: List of functions to check for
:return: True if any of the functions are present
"""
present = {
(
function.sql_name()
if function.sql_name() != "ANONYMOUS"
else function.name.upper()
)
for function in self._parsed.find_all(Func)
}
return any(function.upper() in present for function in functions)
class KQLSplitState(enum.Enum):
"""
@@ -619,6 +637,16 @@ class KustoKQLStatement(BaseSQLStatement[str]):
"""
return KustoKQLStatement(self._sql, self.engine, self._parsed)
def check_functions_present(self, functions: set[str]) -> bool:
"""
Check if any of the given functions are present in the script.
:param functions: List of functions to check for
:return: True if any of the functions are present
"""
logger.warning("Kusto KQL doesn't support checking for functions present.")
return True
class SQLScript:
"""
@@ -684,6 +712,18 @@ class SQLScript:
return script
def check_functions_present(self, functions: set[str]) -> bool:
"""
Check if any of the given functions are present in the script.
:param functions: List of functions to check for
:return: True if any of the functions are present
"""
return any(
statement.check_functions_present(functions)
for statement in self.statements
)
def extract_tables_from_statement(
statement: exp.Expression,
+2 -30
View File
@@ -31,7 +31,6 @@ from sqlalchemy import and_
from sqlparse import keywords
from sqlparse.lexer import Lexer
from sqlparse.sql import (
Function,
Identifier,
IdentifierList,
Parenthesis,
@@ -181,7 +180,7 @@ def check_sql_functions_exist(
:param function_list: The list of functions to search for
:param engine: The engine to use for parsing the SQL statement
"""
return ParsedQuery(sql, engine=engine).check_functions_exist(function_list)
return SQLScript(sql, engine=engine).check_functions_present(function_list)
def strip_comments_from_sql(statement: str, engine: str = "base") -> str:
@@ -229,34 +228,6 @@ class ParsedQuery:
self._tables = self._extract_tables_from_sql()
return self._tables
def _check_functions_exist_in_token(
self, token: Token, functions: set[str]
) -> bool:
if (
isinstance(token, Function)
and token.get_name() is not None
and token.get_name().lower() in functions
):
return True
if hasattr(token, "tokens"):
for inner_token in token.tokens:
if self._check_functions_exist_in_token(inner_token, functions):
return True
return False
def check_functions_exist(self, functions: set[str]) -> bool:
"""
Check if the SQL statement contains any of the specified functions.
:param functions: A set of functions to search for
:return: True if the statement contains any of the specified functions
"""
for statement in self._parsed:
for token in statement.tokens:
if self._check_functions_exist_in_token(token, functions):
return True
return False
def _extract_tables_from_sql(self) -> set[Table]:
"""
Extract all table references in a query.
@@ -277,6 +248,7 @@ class ParsedQuery:
"You may have an error in your SQL statement. {message}"
).format(message=ex.error.message),
level=ErrorLevel.ERROR,
extra=ex.error.extra,
)
) from ex
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -26,7 +26,7 @@ from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Any, Callable, cast, Literal, TYPE_CHECKING
from flask import g, request
from flask import g, has_request_context, request
from flask_appbuilder.const import API_URI_RIS_KEY
from sqlalchemy.exc import SQLAlchemyError
@@ -193,14 +193,15 @@ class AbstractEventLogger(ABC):
# Whenever a user is not bounded to a session we
# need to add them back before logging to capture user_id
if user_id is None:
if user_id is None and has_request_context():
try:
db.session.add(g.user)
user_id = get_user_id()
except Exception as ex: # pylint: disable=broad-except
logging.warning(ex)
actual_user = g.get("user", None)
if actual_user is not None:
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)
user_id = None
payload = collect_request_payload()
if object_ref:
payload["object_ref"] = object_ref
+1 -1
View File
@@ -135,7 +135,7 @@ class Superset(BaseSupersetView):
endpoint_params = {"form_data": f"{form_data}"}
if ReservedUrlParameters.is_standalone_mode():
endpoint_params[ReservedUrlParameters.STANDALONE] = "true"
endpoint_params[ReservedUrlParameters.STANDALONE.value] = "true"
return redirect(url_for("ExploreView.root", **endpoint_params))
def get_query_string_response(self, viz_obj: BaseViz) -> FlaskResponse:
@@ -421,7 +421,8 @@ class TestImportDatabasesCommand(SupersetTestCase):
assert database.database_name == "imported_database"
assert database.expose_in_sqllab
assert database.extra == "{}"
assert database.sqlalchemy_uri == "postgresql://user:pass@host1"
assert database.sqlalchemy_uri == "postgresql://user:XXXXXXXXXX@host1"
assert database.password == "pass" # noqa: S105
db.session.delete(database)
db.session.commit()
@@ -461,7 +462,8 @@ class TestImportDatabasesCommand(SupersetTestCase):
assert database.database_name == "imported_database"
assert database.expose_in_sqllab
assert database.extra == '{"schemas_allowed_for_file_upload": ["upload"]}'
assert database.sqlalchemy_uri == "postgresql://user:pass@host1"
assert database.sqlalchemy_uri == "postgresql://user:XXXXXXXXXX@host1"
assert database.password == "pass" # noqa: S105
db.session.delete(database)
db.session.commit()
@@ -732,7 +734,8 @@ class TestImportDatabasesCommand(SupersetTestCase):
assert database.database_name == "imported_database"
assert database.expose_in_sqllab
assert database.extra == "{}"
assert database.sqlalchemy_uri == "postgresql://user:pass@host1"
assert database.sqlalchemy_uri == "postgresql://user:XXXXXXXXXX@host1"
assert database.password == "pass" # noqa: S105
model_ssh_tunnel = (
db.session.query(SSHTunnel)
@@ -779,7 +782,8 @@ class TestImportDatabasesCommand(SupersetTestCase):
assert database.database_name == "imported_database"
assert database.expose_in_sqllab
assert database.extra == "{}"
assert database.sqlalchemy_uri == "postgresql://user:pass@host1"
assert database.sqlalchemy_uri == "postgresql://user:XXXXXXXXXX@host1"
assert database.password == "pass" # noqa: S105
model_ssh_tunnel = (
db.session.query(SSHTunnel)
@@ -1008,7 +1012,12 @@ class TestTestConnectionDatabaseCommand(SupersetTestCase):
@patch("superset.db_engine_specs.base.is_hostname_valid")
@patch("superset.db_engine_specs.base.is_port_open")
@patch("superset.commands.database.validate.DatabaseDAO")
def test_validate(DatabaseDAO, is_port_open, is_hostname_valid, app_context): # noqa: N803
def test_validate(
mock_database_dao, # noqa: N803
is_port_open,
is_hostname_valid,
app_context,
) -> None:
"""
Test parameter validation.
"""
+1 -4
View File
@@ -580,10 +580,7 @@ def test_get_samples_with_incorrect_cc(test_client, login_as_admin, virtual_data
)
rv = test_client.post(uri, json={})
assert rv.status_code == 422
assert "error" in rv.json
if virtual_dataset.database.db_engine_spec.engine_name == "PostgreSQL":
assert "INCORRECT SQL" in rv.json.get("error")
assert rv.json["errors"][0]["error_type"] == "INVALID_SQL_ERROR"
@with_feature_flags(ALLOW_ADHOC_SUBQUERY=True)
@@ -17,6 +17,7 @@
import copy
import json
from typing import Any
from pytest_mock import MockerFixture
from sqlalchemy.orm.session import Session
@@ -48,3 +49,47 @@ def test_import_database_with_encrypted_extra(
uuid = configs["databases/examples.yaml"]["uuid"]
database = db.session.query(Database).filter_by(uuid=uuid).one()
assert database.encrypted_extra == '{"secret": "info"}'
def test_import_mask_password(
mocker: MockerFixture,
session: Session,
) -> None:
"""
Test that passwords are masked when importing databases.
"""
from superset import db, security_manager
from superset.commands.database.importers.v1 import ImportDatabasesCommand
from superset.models.core import Database
mocker.patch("superset.commands.database.importers.v1.utils.add_permissions")
mocker.patch.object(security_manager, "can_access", return_value=True)
configs: dict[str, dict[str, Any]] = {
"databases/examples.yaml": {
"database_name": "examples",
"sqlalchemy_uri": "postgresql://user:password@localhost:5432/superset",
"cache_timeout": None,
"expose_in_sqllab": True,
"allow_run_async": False,
"allow_ctas": False,
"allow_cvas": False,
"extra": {},
"uuid": "a2dc77af-e654-49bb-b321-40f6b559a1ee",
"version": "1.0.0",
"password": None,
"allow_csv_upload": False,
},
}
engine = db.session.get_bind()
Database.metadata.create_all(engine) # pylint: disable=no-member
ImportDatabasesCommand._import(configs)
uuid = configs["databases/examples.yaml"]["uuid"]
database = db.session.query(Database).filter_by(uuid=uuid).one()
assert (
database.sqlalchemy_uri
== "postgresql://user:XXXXXXXXXX@localhost:5432/superset"
)
assert database.password == "password" # noqa: S105
@@ -642,3 +642,28 @@ def test_update_without_catalog_change(mocker: MockerFixture) -> None:
UpdateDatabaseCommand(1, {}).run()
update_catalog_attribute.assert_not_called()
def test_update_broken_connection(mocker: MockerFixture) -> None:
"""
Test that updating a database with a broken connection works
even if it has to run a query to get the default catalog.
"""
database = mocker.MagicMock()
database.get_default_catalog.side_effect = Exception("Broken connection")
database.id = 1
new_db = mocker.MagicMock()
new_db.get_default_catalog.return_value = "main"
database_dao = mocker.patch("superset.commands.database.update.DatabaseDAO")
database_dao.find_by_id.return_value = database
database_dao.update.return_value = new_db
mocker.patch("superset.commands.database.update.SyncPermissionsCommand")
update_catalog_attribute = mocker.patch.object(
UpdateDatabaseCommand,
"_update_catalog_attribute",
)
UpdateDatabaseCommand(1, {}).run()
update_catalog_attribute.assert_called_once_with(1, "main")
@@ -45,7 +45,8 @@ def test_import_database(mocker: MockerFixture, session: Session) -> None:
config = copy.deepcopy(database_config)
database = import_database(config)
assert database.database_name == "imported_database"
assert database.sqlalchemy_uri == "postgresql://user:pass@host1"
assert database.sqlalchemy_uri == "postgresql://user:XXXXXXXXXX@host1"
assert database.password == "pass" # noqa: S105
assert database.cache_timeout is None
assert database.expose_in_sqllab is True
assert database.allow_run_async is False
@@ -0,0 +1,200 @@
# 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 superset.migrations.shared.utils import create_index, drop_index
# ----- Dummy classes for capturing calls ----- #
class DummyLogger:
def __init__(self):
self.messages = []
def info(self, message):
self.messages.append(message)
class DummyOp:
def __init__(self):
self.called = False
self.call_kwargs = None
def create_index(self, **kwargs):
self.called = True
self.call_kwargs = kwargs
def drop_index(self, **kwargs):
self.called = True
self.call_kwargs = kwargs
# ----- Fake functions to simulate table index checks ----- #
def fake_table_has_index_true(*args, **kwargs):
return True
def fake_table_has_index_false(*args, **kwargs):
return False
# ----- Tests for create_index ----- #
def test_create_index_skips_if_index_exists(monkeypatch):
dummy_logger = DummyLogger()
dummy_op = DummyOp()
# Patch globals in the module where create_index is defined.
monkeypatch.setattr("superset.migrations.shared.utils.logger", dummy_logger)
monkeypatch.setattr("superset.migrations.shared.utils.op", dummy_op)
monkeypatch.setattr(
"superset.migrations.shared.utils.table_has_index", fake_table_has_index_true
)
table_name = "test_table"
index_name = "idx_test"
columns = ["col1", "col2"]
create_index(table_name, index_name, columns, unique=True)
# When the index already exists, op.create_index should not be called.
assert dummy_op.called is False
# And a log message mentioning "already has index" should be generated.
assert any("already has index" in msg for msg in dummy_logger.messages)
def test_create_index_creates_index(monkeypatch):
dummy_logger = DummyLogger()
dummy_op = DummyOp()
monkeypatch.setattr("superset.migrations.shared.utils.logger", dummy_logger)
monkeypatch.setattr("superset.migrations.shared.utils.op", dummy_op)
monkeypatch.setattr(
"superset.migrations.shared.utils.table_has_index", fake_table_has_index_false
)
table_name = "test_table"
index_name = "idx_test"
columns = ["col1", "col2"]
create_index(table_name, index_name, columns, unique=False)
# When the index does not exist, op.create_index should be called.
assert dummy_op.called is True
call_kwargs = dummy_op.call_kwargs
assert call_kwargs.get("table_name") == table_name
assert call_kwargs.get("index_name") == index_name
assert call_kwargs.get("unique") is False
assert call_kwargs.get("columns") == columns
# And a log message mentioning "Creating index" should be generated.
assert any("Creating index" in msg for msg in dummy_logger.messages)
def test_create_unique_index_creates_index(monkeypatch):
dummy_logger = DummyLogger()
dummy_op = DummyOp()
monkeypatch.setattr("superset.migrations.shared.utils.logger", dummy_logger)
monkeypatch.setattr("superset.migrations.shared.utils.op", dummy_op)
monkeypatch.setattr(
"superset.migrations.shared.utils.table_has_index", fake_table_has_index_false
)
table_name = "test_table"
index_name = "idx_test"
columns = ["col1", "col2"]
create_index(table_name, index_name, columns, unique=True)
# When the index does not exist, op.create_index should be called.
assert dummy_op.called is True
call_kwargs = dummy_op.call_kwargs
assert call_kwargs.get("table_name") == table_name
assert call_kwargs.get("index_name") == index_name
assert call_kwargs.get("unique") is True
assert call_kwargs.get("columns") == columns
# And a log message mentioning "Creating index" should be generated.
print(dummy_logger.messages)
assert any("Creating index" in msg for msg in dummy_logger.messages)
def test_create_index_with_not_unique(monkeypatch):
dummy_logger = DummyLogger()
dummy_op = DummyOp()
monkeypatch.setattr("superset.migrations.shared.utils.logger", dummy_logger)
monkeypatch.setattr("superset.migrations.shared.utils.op", dummy_op)
monkeypatch.setattr(
"superset.migrations.shared.utils.table_has_index", fake_table_has_index_false
)
table_name = "test_table"
index_name = "idx_test"
columns = ["col1", "col2"]
create_index(table_name, index_name, columns, unique=False)
# When the index does not exist, op.create_index should be called.
assert dummy_op.called is True
call_kwargs = dummy_op.call_kwargs
assert call_kwargs.get("table_name") == table_name
assert call_kwargs.get("index_name") == index_name
assert call_kwargs.get("unique") is False
assert call_kwargs.get("columns") == columns
# ----- Tests for drop_index ----- #
def test_drop_index_skips_if_index_not_exist(monkeypatch):
dummy_logger = DummyLogger()
dummy_op = DummyOp()
monkeypatch.setattr("superset.migrations.shared.utils.logger", dummy_logger)
monkeypatch.setattr("superset.migrations.shared.utils.op", dummy_op)
monkeypatch.setattr(
"superset.migrations.shared.utils.table_has_index", fake_table_has_index_false
)
table_name = "test_table"
index_name = "idx_test"
drop_index(table_name, index_name)
# When the index does not exist, op.drop_index should not be called.
assert dummy_op.called is False
# And a log message mentioning "doesn't have index" should be generated.
assert any("doesn't have index" in msg for msg in dummy_logger.messages)
def test_drop_index_drops_index_when_exists(monkeypatch):
dummy_logger = DummyLogger()
dummy_op = DummyOp()
monkeypatch.setattr("superset.migrations.shared.utils.logger", dummy_logger)
monkeypatch.setattr("superset.migrations.shared.utils.op", dummy_op)
monkeypatch.setattr(
"superset.migrations.shared.utils.table_has_index", fake_table_has_index_true
)
table_name = "test_table"
index_name = "idx_test"
drop_index(table_name, index_name)
# When the index exists, op.drop_index should be called.
assert dummy_op.called is True
call_kwargs = dummy_op.call_kwargs
assert call_kwargs.get("table_name") == table_name
assert call_kwargs.get("index_name") == index_name
# And a log message mentioning "Dropping index" should be generated.
assert any("Dropping index" in msg for msg in dummy_logger.messages)
+29
View File
@@ -1237,6 +1237,35 @@ def test_check_sql_functions_exist() -> None:
)
def test_check_sql_functions_exist_with_comments() -> None:
"""
Test sql functions are detected correctly with comments
"""
assert not (
check_sql_functions_exist(
"select a, b from version/**/", {"version"}, "postgresql"
)
)
assert check_sql_functions_exist("select version/**/()", {"version"}, "postgresql")
assert check_sql_functions_exist(
"select version from version/**/()", {"version"}, "postgresql"
)
assert check_sql_functions_exist(
"select 1, a.version from (select version from version/**/()) as a",
{"version"},
"postgresql",
)
assert check_sql_functions_exist(
"select 1, a.version from (select version/**/()) as a",
{"version"},
"postgresql",
)
def test_sanitize_clause_valid():
# regular clauses
assert sanitize_clause("col = 1") == "col = 1"