Compare commits

...
Author SHA1 Message Date
Elizabeth Thompson 2817aebd69 update changelog 2023-03-20 15:00:37 -07:00
Elizabeth Thompson d80e67d819 bump package-lock version 2023-03-20 14:33:21 -07:00
ʈᵃᵢ b92e4fb49e fix(webdriver): default WEBDRIVER_OPTION_ARGS and update Firefox deps (#23388) 2023-03-20 14:27:53 -07:00
Elizabeth Thompson b6b9a925e4 update changelog 2023-03-13 18:23:16 -07:00
Elizabeth Thompson 4f6b83a050 update releasing process for testing 2023-03-13 18:23:14 -07:00
Elizabeth Thompson e42c2a7ab3 chore: use util test env for init check (#23325) 2023-03-13 16:57:06 -07:00
Elizabeth Thompson 29e36b4cb3 update changelog 2023-03-13 16:57:06 -07:00
Daniel Vaz Gaspar 231d39ae2e docs: improve API v1 migration documentation (#23298) 2023-03-13 16:57:06 -07:00
Kamil Gabryjelski 81a13189b0 fix(dashboard): Infinite load when filter with default first value is out of scope (#23299) 2023-03-13 16:57:06 -07:00
Kamil Gabryjelski 224f962e46 fix(dashboard): Charts crashing when cross filter on adhoc column is applied (#23238)
Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
(cherry picked from commit 42980a69a7)
2023-03-13 16:57:05 -07:00
Lily Kuang d670cb6a7f fix: customize tab on heatmap chart is blank (#23243)
(cherry picked from commit 1f3eb56688)
2023-03-13 16:57:05 -07:00
Ville Brofeldt a336e685cd fix(dao): use explicit id filter (#23246)
(cherry picked from commit 5a099e0762)
2023-03-13 16:57:05 -07:00
Daniel Vaz Gaspar c60ba87d0f fix: refuse to start with default secret on non debug envs (#23186)
(cherry picked from commit b180319bbf)
2023-03-13 16:57:05 -07:00
Elizabeth Thompson 1f04b17f44 docs: update installation docs to include frontend asset build (#23199)
(cherry picked from commit ae8aa60844)
2023-03-13 16:57:05 -07:00
Kamil Gabryjelski bac5babff8 fix(dashboard): Don't show cross filters checkbox to users without write permissions (#23237)
(cherry picked from commit 473a69a704)
2023-03-13 16:57:05 -07:00
Daniel Vaz Gaspar 2f3758278f fix: add disallowed query params for engines specs (#23217)
Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
(cherry picked from commit b479e93b49)
2023-03-13 16:57:05 -07:00
Daniel Vaz Gaspar b14e53e492 fix: memoized decorator memory leak (#23139)
(cherry picked from commit 79274eb5bc)
2023-03-13 16:57:05 -07:00
Kamil Gabryjelski 22bab714d6 fix(dashboard): Focusing charts and native filters from filters badge (#23190)
(cherry picked from commit 7d4aee956e)
2023-03-13 16:57:05 -07:00
Ville Brofeldt a94e67243b fix(clickhouse): add missing default format (#23192)
(cherry picked from commit 967383853c)
2023-03-13 16:57:05 -07:00
Ville Brofeldt 7d014ad9dd fix(clickhouse): add clickhouse connect driver (#23185)
(cherry picked from commit d0c54cddb0)
2023-03-13 16:57:05 -07:00
Daniel Vaz Gaspar 0082cf6a02 fix: bump FAB to 4.3.0 (#23184)
(cherry picked from commit f0f27a486d)
2023-03-13 16:57:05 -07:00
Ville Brofeldt ae6e2a00a0 fix(rbac): show objects accessible by database access perm (#23118)
(cherry picked from commit 89576f8a87)
2023-03-13 16:57:05 -07:00
Hugh A. Miles II 9096e27794 chore: Add docs for ssh tunneling (#23131)
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
(cherry picked from commit a0ca0c04ff)
2023-03-13 16:57:05 -07:00
Elizabeth Thompson 649b355767 bump version, changelog and updating for 2.1 2023-02-28 10:02:21 -08:00
Hugh A. Miles II f5a5c261e0 fix(ssh-tunnel): add password to from_private_key function (#23175)
(cherry picked from commit cb9bff72d6)
2023-02-28 10:02:21 -08:00
Antonio Rivero e0a394fe9b fix(ssh_tunnel): Display SSHTunnel Switch when editing a DB that was created with the Dynamic Form (#23195)
(cherry picked from commit 218de6e6a4)
2023-02-28 10:02:21 -08:00
Hugh A. Miles II a6d714b0c4 fix(sshtunnel): argument params to properly setting server_port (#23196)
(cherry picked from commit 196e3eac8b)
2023-02-28 10:02:21 -08:00
Daniel Vaz Gaspar 7a4cd44a5d fix: reorganize role permissions (#23096)
(cherry picked from commit d4362a3676)
2023-02-22 14:03:53 -08:00
80 changed files with 2355 additions and 632 deletions
@@ -50,6 +50,8 @@ jobs:
mkdir ${{ github.workspace }}/.temp mkdir ${{ github.workspace }}/.temp
- name: Python unit tests - name: Python unit tests
if: steps.check.outcome == 'failure' if: steps.check.outcome == 'failure'
env:
SUPERSET_TESTENV: true
run: | run: |
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear
- name: Upload code coverage - name: Upload code coverage
+1175 -2
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -114,7 +114,14 @@ COPY ./requirements/*.txt ./docker/requirements-*.txt/ /app/requirements/
USER root USER root
RUN apt-get update -y \ RUN apt-get update -y \
&& apt-get install -y --no-install-recommends libnss3 libdbus-glib-1-2 libgtk-3-0 libx11-xcb1 wget && apt-get install -y --no-install-recommends \
libnss3 \
libdbus-glib-1-2 \
libgtk-3-0 \
libx11-xcb1 \
libasound2 \
libxtst6 \
wget
# Install GeckoDriver WebDriver # Install GeckoDriver WebDriver
RUN wget https://github.com/mozilla/geckodriver/releases/download/${GECKODRIVER_VERSION}/geckodriver-${GECKODRIVER_VERSION}-linux64.tar.gz -O /tmp/geckodriver.tar.gz && \ RUN wget https://github.com/mozilla/geckodriver/releases/download/${GECKODRIVER_VERSION}/geckodriver-${GECKODRIVER_VERSION}-linux64.tar.gz -O /tmp/geckodriver.tar.gz && \
+2 -1
View File
@@ -61,6 +61,7 @@ RUN pip install --upgrade setuptools pip \
RUN flask fab babel-compile --target superset/translations RUN flask fab babel-compile --target superset/translations
ENV PATH=/home/superset/superset/bin:$PATH \ ENV PATH=/home/superset/superset/bin:$PATH \
PYTHONPATH=/home/superset/superset/:$PYTHONPATH PYTHONPATH=/home/superset/superset/:$PYTHONPATH \
SUPERSET_TESTENV=true
COPY from_tarball_entrypoint.sh /entrypoint.sh COPY from_tarball_entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+4
View File
@@ -19,6 +19,10 @@ set -ex
echo "[WARNING] this entrypoint creates an admin/admin user" echo "[WARNING] this entrypoint creates an admin/admin user"
echo "[WARNING] it should only be used for lightweight testing/validation" echo "[WARNING] it should only be used for lightweight testing/validation"
if [ "$SUPERSET_TESTENV" = "true" ]
then
echo "SUPERSET IS RUNNING IN TEST MODE"
fi
# Create an admin user (you will be prompted to set username, first and last name before setting a password) # Create an admin user (you will be prompted to set username, first and last name before setting a password)
superset fab create-admin \ superset fab create-admin \
+1
View File
@@ -61,6 +61,7 @@ These features are **finished** but currently being tested. They are usable, but
- GENERIC_CHART_AXES - GENERIC_CHART_AXES
- GLOBAL_ASYNC_QUERIES [(docs)](https://github.com/apache/superset/blob/master/CONTRIBUTING.md#async-chart-queries) - GLOBAL_ASYNC_QUERIES [(docs)](https://github.com/apache/superset/blob/master/CONTRIBUTING.md#async-chart-queries)
- RLS_IN_SQLLAB - RLS_IN_SQLLAB
- SSH_TUNNELING [(docs)](https://superset.apache.org/docs/installation/setup-ssh-tunneling)
- USE_ANALAGOUS_COLORS - USE_ANALAGOUS_COLORS
- UX_BETA - UX_BETA
- VERSIONED_EXPORT - VERSIONED_EXPORT
+17 -4
View File
@@ -22,8 +22,20 @@ under the License.
This file documents any backwards-incompatible changes in Superset and This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version. assists people when migrating to a new version.
## Next ## 2.1.0
- [22809](https://github.com/apache/superset/pull/22809): Migrated endpoint `/superset/sql_json` and `/superset/results/` to `/api/v1/sqllab/execute/` and `/api/v1/sqllab/results/` respectively. Corresponding permissions are `can sql_json on Superset` to `can execute on SQLLab`, `can results on Superset` to `can results on SQLLab`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22931](https://github.com/apache/superset/pull/22931): Migrated endpoint `/superset/get_or_create_table/` to `/api/v1/dataset/get_or_create/`. Corresponding permissions are `can get or create table on Superset` to `can get or create dataset on Dataset`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22882](https://github.com/apache/superset/pull/22882): Migrated endpoint `/superset/filter/<datasource_type>/<int:datasource_id>/<column>/` to `/api/v1/datasource/<datasource_type>/<datasource_id>/column/<column_name>/values/`. Corresponding permissions are `can filter on Superset` to `can get column values on Datasource`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22789](https://github.com/apache/superset/pull/22789): Migrated endpoint `/superset/recent_activity/<user_id>/` to `/api/v1/log/recent_activity/<user_id>/`. Corresponding permissions are `can recent activity on Superset` to `can recent activity on Log`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22913](https://github.com/apache/superset/pull/22913): Migrated endpoint `/superset/csv` to `/api/v1/sqllab/export/`. Corresponding permissions are `can csv on Superset` to `can export csv on SQLLab`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22496](https://github.com/apache/superset/pull/22496): Migrated endpoint `/superset/slice_json/<int:layer_id>` to `/api/v1/chart/<int:id>/data/`. Corresponding permissions are `can slice json on Superset` to `can read on Chart`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22496](https://github.com/apache/superset/pull/22496): Migrated endpoint `/superset/annotation_json/<int:layer_id>` to `/api/v1/chart/<int:id>/data/`. Corresponding permissions are `can annotation json on Superset` to `can read on Chart`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22624](https://github.com/apache/superset/pull/22624): Migrated endpoint `/superset/stop_query/` to `/api/v1/query/stop`. Corresponding permissions are `can stop query on Superset` to `can read on Query`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22579](https://github.com/apache/superset/pull/22579): Migrated endpoint `/superset/search_queries/` to `/api/v1/query/`. Corresponding permissions are `can search queries on Superset` to `can read on Query`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22501](https://github.com/apache/superset/pull/22501): Migrated endpoint `/superset/tables/<int:db_id>/<schema>/` to `/api/v1/database/<int:id>/tables/`. Corresponding permissions are `can tables on Superset` to `can read on Database`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [22611](https://github.com/apache/superset/pull/22611): Migrated endpoint `/superset/queries/` to `api/v1/query/updated_since`. Corresponding permissions are `can queries on Superset` to `can read on Query`. Make sure you add/replace the necessary permissions on any custom roles you may have.
- [23186](https://github.com/apache/superset/pull/23186): Superset will refuse to start if a default `SECRET_KEY` is detected on a non Flask debug setting.
- [22022](https://github.com/apache/superset/pull/22022): HTTP API endpoints `/superset/approve` and `/superset/request_access` have been deprecated and their HTTP methods were changed from GET to POST - [22022](https://github.com/apache/superset/pull/22022): HTTP API endpoints `/superset/approve` and `/superset/request_access` have been deprecated and their HTTP methods were changed from GET to POST
- [20606](https://github.com/apache/superset/pull/20606): When user clicks on chart title or "Edit chart" button in Dashboard page, Explore opens in the same tab. Clicking while holding cmd/ctrl opens Explore in a new tab. To bring back the old behaviour (always opening Explore in a new tab), flip feature flag `DASHBOARD_EDIT_CHART_IN_NEW_TAB` to `True`. - [20606](https://github.com/apache/superset/pull/20606): When user clicks on chart title or "Edit chart" button in Dashboard page, Explore opens in the same tab. Clicking while holding cmd/ctrl opens Explore in a new tab. To bring back the old behaviour (always opening Explore in a new tab), flip feature flag `DASHBOARD_EDIT_CHART_IN_NEW_TAB` to `True`.
- [20799](https://github.com/apache/superset/pull/20799): Presto and Trino engine will now display tracking URL for running queries in SQL Lab. If for some reason you don't want to show the tracking URL (for example, when your data warehouse hasn't enabled access for to Presto or Trino UI), update `TRACKING_URL_TRANSFORMER` in `config.py` to return `None`. - [20799](https://github.com/apache/superset/pull/20799): Presto and Trino engine will now display tracking URL for running queries in SQL Lab. If for some reason you don't want to show the tracking URL (for example, when your data warehouse hasn't enabled access for to Presto or Trino UI), update `TRACKING_URL_TRANSFORMER` in `config.py` to return `None`.
@@ -31,19 +43,20 @@ assists people when migrating to a new version.
- [21163](https://github.com/apache/superset/pull/21163): The time grain will be decoupled from the time filter column and the time grain control will move below the X-Axis control when `GENERIC_CHART_AXES` feature flags set to `True`. The time grain will be applied on the time column in the column-like controls(x axis, dimensions) instead of the time column in the time section. - [21163](https://github.com/apache/superset/pull/21163): The time grain will be decoupled from the time filter column and the time grain control will move below the X-Axis control when `GENERIC_CHART_AXES` feature flags set to `True`. The time grain will be applied on the time column in the column-like controls(x axis, dimensions) instead of the time column in the time section.
- [21284](https://github.com/apache/superset/pull/21284): The non-functional `MAX_TABLE_NAMES` config key has been removed. - [21284](https://github.com/apache/superset/pull/21284): The non-functional `MAX_TABLE_NAMES` config key has been removed.
- [21794](https://github.com/apache/superset/pull/21794): Deprecates the undocumented `PRESTO_SPLIT_VIEWS_FROM_TABLES` feature flag. Now for Presto, like other engines, only physical tables are treated as tables. - [21794](https://github.com/apache/superset/pull/21794): Deprecates the undocumented `PRESTO_SPLIT_VIEWS_FROM_TABLES` feature flag. Now for Presto, like other engines, only physical tables are treated as tables.
### Breaking Changes
- [22798](https://github.com/apache/superset/pull/22798): To make the welcome page more relevant in production environments, the last tab on the welcome page has been changed from to feature all charts/dashboards the user has access to (previously only examples were shown). To keep current behavior unchanged, add the following to your `superset_config.py`: `WELCOME_PAGE_LAST_TAB = "examples"` - [22798](https://github.com/apache/superset/pull/22798): To make the welcome page more relevant in production environments, the last tab on the welcome page has been changed from to feature all charts/dashboards the user has access to (previously only examples were shown). To keep current behavior unchanged, add the following to your `superset_config.py`: `WELCOME_PAGE_LAST_TAB = "examples"`
- [22328](https://github.com/apache/superset/pull/22328): For deployments that have enabled the "THUMBNAILS" feature flag, the function that calculates dashboard digests has been updated to consider additional properties to more accurately identify changes in the dashboard metadata. This change will invalidate all currently cached dashboard thumbnails. - [22328](https://github.com/apache/superset/pull/22328): For deployments that have enabled the "THUMBNAILS" feature flag, the function that calculates dashboard digests has been updated to consider additional properties to more accurately identify changes in the dashboard metadata. This change will invalidate all currently cached dashboard thumbnails.
- [21765](https://github.com/apache/superset/pull/21765): For deployments that have enabled the "ALERT_REPORTS" feature flag, Gamma users will no longer have read and write access to Alerts & Reports by default. To give Gamma users the ability to schedule reports from the Dashboard and Explore view like before, create an additional role with "can read on ReportSchedule" and "can write on ReportSchedule" permissions. To further give Gamma users access to the "Alerts & Reports" menu and CRUD view, add "menu access on Manage" and "menu access on Alerts & Report" permissions to the role. - [21765](https://github.com/apache/superset/pull/21765): For deployments that have enabled the "ALERT_REPORTS" feature flag, Gamma users will no longer have read and write access to Alerts & Reports by default. To give Gamma users the ability to schedule reports from the Dashboard and Explore view like before, create an additional role with "can read on ReportSchedule" and "can write on ReportSchedule" permissions. To further give Gamma users access to the "Alerts & Reports" menu and CRUD view, add "menu access on Manage" and "menu access on Alerts & Report" permissions to the role.
### Breaking Changes
### Potential Downtime ### Potential Downtime
- [21284](https://github.com/apache/superset/pull/21284): A change which drops the unused `dbs.allow_multi_schema_metadata_fetch` column via a (potentially locking) DDL operation. - [21284](https://github.com/apache/superset/pull/21284): A change which drops the unused `dbs.allow_multi_schema_metadata_fetch` column via a (potentially locking) DDL operation.
### Other ### Other
- [23118](https://github.com/apache/superset/pull/23118): Previously the "database access on <database>" permission granted access to all datasets on the underlying database, but they didn't show up on the list views. Now all dashboards, charts and datasets that are accessible via this permission will also show up on their respective list views.
## 2.0.1 ## 2.0.1
- [21895](https://github.com/apache/superset/pull/21895): Markdown components had their security increased by adhering to the same sanitization process enforced by Github. This means that some HTML elements found in markdowns are not allowed anymore due to the security risks they impose. If you're deploying Superset in a trusted environment and wish to use some of the blocked elements, then you can use the HTML_SANITIZATION_SCHEMA_EXTENSIONS configuration to extend the default sanitization schema. There's also the option to disable HTML sanitization using the HTML_SANITIZATION configuration but we do not recommend this approach because of the security risks. Given the provided configurations, we don't view the improved sanitization as a breaking change but as a security patch. - [21895](https://github.com/apache/superset/pull/21895): Markdown components had their security increased by adhering to the same sanitization process enforced by Github. This means that some HTML elements found in markdowns are not allowed anymore due to the security risks they impose. If you're deploying Superset in a trusted environment and wish to use some of the blocked elements, then you can use the HTML_SANITIZATION_SCHEMA_EXTENSIONS configuration to extend the default sanitization schema. There's also the option to disable HTML sanitization using the HTML_SANITIZATION configuration but we do not recommend this approach because of the security risks. Given the provided configurations, we don't view the improved sanitization as a breaking change but as a security patch.
+1
View File
@@ -42,6 +42,7 @@ REDIS_PORT=6379
FLASK_ENV=production FLASK_ENV=production
SUPERSET_ENV=production SUPERSET_ENV=production
SUPERSET_LOAD_EXAMPLES=yes SUPERSET_LOAD_EXAMPLES=yes
SUPERSET_SECRET_KEY=TEST_NON_DEV_SECRET
CYPRESS_CONFIG=false CYPRESS_CONFIG=false
SUPERSET_PORT=8088 SUPERSET_PORT=8088
MAPBOX_API_KEY='' MAPBOX_API_KEY=''
@@ -23,8 +23,8 @@ SUPERSET_WEBSERVER_PORT = 8088
# Your App secret key will be used for securely signing the session cookie # Your App secret key will be used for securely signing the session cookie
# and encrypting sensitive information on the database # and encrypting sensitive information on the database
# Make sure you are changing this key for your deployment with a strong key. # Make sure you are changing this key for your deployment with a strong key.
# You can generate a strong key using `openssl rand -base64 42` # You can generate a strong key using `openssl rand -base64 42`.
# Alternatively you can set it with `SUPERSET_SECRET_KEY` environment variable.
SECRET_KEY = 'YOUR_OWN_RANDOM_GENERATED_SECRET_KEY' SECRET_KEY = 'YOUR_OWN_RANDOM_GENERATED_SECRET_KEY'
# The SQLAlchemy connection string to your database backend # The SQLAlchemy connection string to your database backend
@@ -138,6 +138,12 @@ superset load_examples
# Create default roles and permissions # Create default roles and permissions
superset init superset init
# Build javascript assets
cd superset-frontend
npm ci
npm run build
cd ..
# To start a development web server on port 8088, use -p to bind to another port # To start a development web server on port 8088, use -p to bind to another port
superset run -p 8088 --with-threads --reload --debugger superset run -p 8088 --with-threads --reload --debugger
``` ```
@@ -0,0 +1,21 @@
---
title: Setup SSH Tunneling
hide_title: true
sidebar_position: 13
version: 1
---
## SSH Tunneling
1. Turn on feature flag
- Change [`SSH_TUNNELING`](https://github.com/apache/superset/blob/eb8386e3f0647df6d1bbde8b42073850796cc16f/superset/config.py#L489) to `True`
- If you want to add more security when establishing the tunnel we allow users to overwrite the `SSHTunnelManager` class (here)[https://github.com/apache/superset/blob/eb8386e3f0647df6d1bbde8b42073850796cc16f/superset/config.py#L507]
- You can also set the [`SSH_TUNNEL_LOCAL_BIND_ADDRESS`](https://github.com/apache/superset/blob/eb8386e3f0647df6d1bbde8b42073850796cc16f/superset/config.py#L508) this the host address where the tunnel will be accessible on your VPC
2. Create database w/ ssh tunnel enabled
- With the feature flag enabled you should now see ssh tunnel toggle.
- Click the toggle to enables ssh tunneling and add your credentials accordingly.
- Superset allows for 2 different type authenticaion (Basic + Private Key). These credentials should come from your service provider.
3. Verify data is flowing
- Once SSH tunneling has been enabled, go to SQL Lab and write a query to verify data is properly flowing.
+1 -1
View File
@@ -82,7 +82,7 @@ flask==2.1.3
# flask-migrate # flask-migrate
# flask-sqlalchemy # flask-sqlalchemy
# flask-wtf # flask-wtf
flask-appbuilder==4.2.0 flask-appbuilder==4.3.0
# via apache-superset # via apache-superset
flask-babel==1.0.0 flask-babel==1.0.0
# via flask-appbuilder # via flask-appbuilder
+1 -1
View File
@@ -83,7 +83,7 @@ setup(
"cryptography>=39.0.0,<40", "cryptography>=39.0.0,<40",
"deprecation>=2.1.0, <2.2.0", "deprecation>=2.1.0, <2.2.0",
"flask>=2.1.3, <2.2", "flask>=2.1.3, <2.2",
"flask-appbuilder>=4.2.0, <5.0.0", "flask-appbuilder>=4.3.0, <5.0.0",
"flask-caching>=1.10.1, <1.11", "flask-caching>=1.10.1, <1.11",
"flask-compress>=1.13, <2.0", "flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0", "flask-talisman>=1.0.0, <2.0",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "superset", "name": "superset",
"version": "0.0.0-dev", "version": "2.1.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "superset", "name": "superset",
"version": "0.0.0-dev", "version": "2.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "superset", "name": "superset",
"version": "0.0.0-dev", "version": "2.1.0",
"description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.", "description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.",
"keywords": [ "keywords": [
"big", "big",
@@ -442,8 +442,6 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const dashboardIsSaving = useSelector<RootState, boolean>( const dashboardIsSaving = useSelector<RootState, boolean>(
({ dashboardState }) => dashboardState.dashboardIsSaving, ({ dashboardState }) => dashboardState.dashboardIsSaving,
); );
const nativeFilters = useSelector((state: RootState) => state.nativeFilters);
const focusedFilterId = nativeFilters?.focusedFilterId;
const fullSizeChartId = useSelector<RootState, number | null>( const fullSizeChartId = useSelector<RootState, number | null>(
state => state.dashboardState.fullSizeChartId, state => state.dashboardState.fullSizeChartId,
); );
@@ -580,10 +578,7 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
{!hideDashboardHeader && <DashboardHeader />} {!hideDashboardHeader && <DashboardHeader />}
{showFilterBar && {showFilterBar &&
filterBarOrientation === FilterBarOrientation.HORIZONTAL && ( filterBarOrientation === FilterBarOrientation.HORIZONTAL && (
<FilterBar <FilterBar orientation={FilterBarOrientation.HORIZONTAL} />
focusedFilterId={focusedFilterId}
orientation={FilterBarOrientation.HORIZONTAL}
/>
)} )}
{dropIndicatorProps && <div {...dropIndicatorProps} />} {dropIndicatorProps && <div {...dropIndicatorProps} />}
{!isReport && topLevelTabs && !uiConfig.hideNav && ( {!isReport && topLevelTabs && !uiConfig.hideNav && (
@@ -613,7 +608,6 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
</div> </div>
), ),
[ [
focusedFilterId,
nativeFiltersEnabled, nativeFiltersEnabled,
filterBarOrientation, filterBarOrientation,
editMode, editMode,
@@ -658,7 +652,6 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
<ErrorBoundary> <ErrorBoundary>
{!isReport && ( {!isReport && (
<FilterBar <FilterBar
focusedFilterId={focusedFilterId}
orientation={FilterBarOrientation.VERTICAL} orientation={FilterBarOrientation.VERTICAL}
verticalConfig={{ verticalConfig={{
filtersOpen: dashboardFiltersOpen, filtersOpen: dashboardFiltersOpen,
@@ -23,7 +23,13 @@ import cx from 'classnames';
import { DataMaskStateWithId, Filters } from '@superset-ui/core'; import { DataMaskStateWithId, Filters } from '@superset-ui/core';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { usePrevious } from 'src/hooks/usePrevious'; import { usePrevious } from 'src/hooks/usePrevious';
import { setFocusedNativeFilter } from 'src/dashboard/actions/nativeFilters'; import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState';
import {
ChartsState,
DashboardInfo,
DashboardLayout,
RootState,
} from 'src/dashboard/types';
import DetailsPanelPopover from './DetailsPanel'; import DetailsPanelPopover from './DetailsPanel';
import { Pill } from './Styles'; import { Pill } from './Styles';
import { import {
@@ -32,12 +38,6 @@ import {
selectIndicatorsForChart, selectIndicatorsForChart,
selectNativeIndicatorsForChart, selectNativeIndicatorsForChart,
} from './selectors'; } from './selectors';
import {
ChartsState,
DashboardInfo,
DashboardLayout,
RootState,
} from '../../types';
export interface FiltersBadgeProps { export interface FiltersBadgeProps {
chartId: number; chartId: number;
@@ -87,7 +87,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
const onHighlightFilterSource = useCallback( const onHighlightFilterSource = useCallback(
(path: string[]) => { (path: string[]) => {
dispatch(setFocusedNativeFilter(path[0])); dispatch(setDirectPathToChild(path));
}, },
[dispatch], [dispatch],
); );
@@ -23,6 +23,7 @@ import {
FeatureFlag, FeatureFlag,
Filters, Filters,
FilterState, FilterState,
getColumnLabel,
isFeatureEnabled, isFeatureEnabled,
NativeFilterType, NativeFilterType,
NO_TIME_RANGE, NO_TIME_RANGE,
@@ -145,8 +146,8 @@ const getAppliedColumns = (chart: any): Set<string> =>
const getRejectedColumns = (chart: any): Set<string> => const getRejectedColumns = (chart: any): Set<string> =>
new Set( new Set(
(chart?.queriesResponse?.[0]?.rejected_filters || []).map( (chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) =>
(filter: any) => filter.column, getColumnLabel(filter.column),
), ),
); );
@@ -144,7 +144,7 @@ const FilterBarSettings = () => {
); );
const menuItems: DropDownSelectableProps['menuItems'] = []; const menuItems: DropDownSelectableProps['menuItems'] = [];
if (isCrossFiltersFeatureEnabled) { if (isCrossFiltersFeatureEnabled && canEdit) {
menuItems.unshift({ menuItems.unshift({
key: crossFiltersMenuKey, key: crossFiltersMenuKey,
label: crossFiltersMenuItem, label: crossFiltersMenuItem,
@@ -222,7 +222,6 @@ const FilterControl = ({
filter, filter,
icon, icon,
onFilterSelectionChange, onFilterSelectionChange,
focusedFilterId,
inView, inView,
showOverflow, showOverflow,
parentRef, parentRef,
@@ -288,7 +287,6 @@ const FilterControl = ({
dataMaskSelected={dataMaskSelected} dataMaskSelected={dataMaskSelected}
filter={filter} filter={filter}
showOverflow={showOverflow} showOverflow={showOverflow}
focusedFilterId={focusedFilterId}
onFilterSelectionChange={onFilterSelectionChange} onFilterSelectionChange={onFilterSelectionChange}
inView={inView} inView={inView}
parentRef={parentRef} parentRef={parentRef}
@@ -54,15 +54,14 @@ import Icons from 'src/components/Icons';
import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible'; import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
import { useFilterControlFactory } from '../useFilterControlFactory'; import { useFilterControlFactory } from '../useFilterControlFactory';
import { FiltersDropdownContent } from '../FiltersDropdownContent'; import { FiltersDropdownContent } from '../FiltersDropdownContent';
import { useFilterOutlined } from '../useFilterOutlined';
type FilterControlsProps = { type FilterControlsProps = {
focusedFilterId?: string;
dataMaskSelected: DataMaskStateWithId; dataMaskSelected: DataMaskStateWithId;
onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void; onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void;
}; };
const FilterControls: FC<FilterControlsProps> = ({ const FilterControls: FC<FilterControlsProps> = ({
focusedFilterId,
dataMaskSelected, dataMaskSelected,
onFilterSelectionChange, onFilterSelectionChange,
}) => { }) => {
@@ -73,12 +72,13 @@ const FilterControls: FC<FilterControlsProps> = ({
: FilterBarOrientation.VERTICAL, : FilterBarOrientation.VERTICAL,
); );
const { outlinedFilterId, lastUpdated } = useFilterOutlined();
const [overflowedIds, setOverflowedIds] = useState<string[]>([]); const [overflowedIds, setOverflowedIds] = useState<string[]>([]);
const popoverRef = useRef<DropdownContainerRef>(null); const popoverRef = useRef<DropdownContainerRef>(null);
const { filterControlFactory, filtersWithValues } = useFilterControlFactory( const { filterControlFactory, filtersWithValues } = useFilterControlFactory(
dataMaskSelected, dataMaskSelected,
focusedFilterId,
onFilterSelectionChange, onFilterSelectionChange,
); );
const portalNodes = useMemo(() => { const portalNodes = useMemo(() => {
@@ -94,6 +94,11 @@ const FilterControls: FC<FilterControlsProps> = ({
const [filtersInScope, filtersOutOfScope] = const [filtersInScope, filtersOutOfScope] =
useSelectFiltersInScope(filtersWithValues); useSelectFiltersInScope(filtersWithValues);
const hasRequiredFirst = useMemo(
() => filtersWithValues.some(filter => filter.requiredFirst),
[filtersWithValues],
);
const dashboardHasTabs = useDashboardHasTabs(); const dashboardHasTabs = useDashboardHasTabs();
const showCollapsePanel = dashboardHasTabs && filtersWithValues.length > 0; const showCollapsePanel = dashboardHasTabs && filtersWithValues.length > 0;
@@ -119,6 +124,7 @@ const FilterControls: FC<FilterControlsProps> = ({
{showCollapsePanel && ( {showCollapsePanel && (
<FiltersOutOfScopeCollapsible <FiltersOutOfScopeCollapsible
filtersOutOfScope={filtersOutOfScope} filtersOutOfScope={filtersOutOfScope}
forceRender={hasRequiredFirst}
hasTopMargin={filtersInScope.length > 0} hasTopMargin={filtersInScope.length > 0}
renderer={renderer} renderer={renderer}
/> />
@@ -200,6 +206,7 @@ const FilterControls: FC<FilterControlsProps> = ({
filtersOutOfScope={filtersOutOfScope} filtersOutOfScope={filtersOutOfScope}
renderer={renderer} renderer={renderer}
showCollapsePanel={showCollapsePanel} showCollapsePanel={showCollapsePanel}
forceRenderOutOfScope={hasRequiredFirst}
/> />
) )
: undefined : undefined
@@ -234,10 +241,10 @@ const FilterControls: FC<FilterControlsProps> = ({
}, [filtersOutOfScope, filtersWithValues, overflowedFiltersInScope]); }, [filtersOutOfScope, filtersWithValues, overflowedFiltersInScope]);
useEffect(() => { useEffect(() => {
if (focusedFilterId && overflowedIds.includes(focusedFilterId)) { if (outlinedFilterId && overflowedIds.includes(outlinedFilterId)) {
popoverRef?.current?.open(); popoverRef?.current?.open();
} }
}, [focusedFilterId, popoverRef, overflowedIds]); }, [outlinedFilterId, lastUpdated, popoverRef, overflowedIds]);
return ( return (
<> <>
@@ -43,13 +43,17 @@ import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { waitForAsyncData } from 'src/middleware/asyncEvent'; import { waitForAsyncData } from 'src/middleware/asyncEvent';
import { ClientErrorObject } from 'src/utils/getClientErrorObject'; import { ClientErrorObject } from 'src/utils/getClientErrorObject';
import { FilterBarOrientation, RootState } from 'src/dashboard/types'; import { FilterBarOrientation, RootState } from 'src/dashboard/types';
import { onFiltersRefreshSuccess } from 'src/dashboard/actions/dashboardState'; import {
onFiltersRefreshSuccess,
setDirectPathToChild,
} from 'src/dashboard/actions/dashboardState';
import { FAST_DEBOUNCE } from 'src/constants'; import { FAST_DEBOUNCE } from 'src/constants';
import { dispatchHoverAction, dispatchFocusAction } from './utils'; import { dispatchHoverAction, dispatchFocusAction } from './utils';
import { FilterControlProps } from './types'; import { FilterControlProps } from './types';
import { getFormData } from '../../utils'; import { getFormData } from '../../utils';
import { useFilterDependencies } from './state'; import { useFilterDependencies } from './state';
import { checkIsMissingRequiredValue } from '../utils'; import { checkIsMissingRequiredValue } from '../utils';
import { useFilterOutlined } from '../useFilterOutlined';
const HEIGHT = 32; const HEIGHT = 32;
@@ -79,7 +83,6 @@ const useShouldFilterRefresh = () => {
const FilterValue: React.FC<FilterControlProps> = ({ const FilterValue: React.FC<FilterControlProps> = ({
dataMaskSelected, dataMaskSelected,
filter, filter,
focusedFilterId,
onFilterSelectionChange, onFilterSelectionChange,
inView = true, inView = true,
showOverflow, showOverflow,
@@ -111,6 +114,8 @@ const FilterValue: React.FC<FilterControlProps> = ({
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const dispatch = useDispatch(); const dispatch = useDispatch();
const { outlinedFilterId, lastUpdated } = useFilterOutlined();
const handleFilterLoadFinish = useCallback(() => { const handleFilterLoadFinish = useCallback(() => {
setIsRefreshing(false); setIsRefreshing(false);
setIsLoading(false); setIsLoading(false);
@@ -212,26 +217,34 @@ const FilterValue: React.FC<FilterControlProps> = ({
]); ]);
useEffect(() => { useEffect(() => {
if (focusedFilterId && focusedFilterId === filter.id) { if (outlinedFilterId && outlinedFilterId === filter.id) {
setTimeout(() => { setTimeout(
inputRef?.current?.focus(); () => {
}, FAST_DEBOUNCE); inputRef?.current?.focus();
},
overflow ? FAST_DEBOUNCE : 0,
);
} }
}, [inputRef, focusedFilterId, filter.id]); }, [inputRef, outlinedFilterId, lastUpdated, filter.id, overflow]);
const setDataMask = useCallback( const setDataMask = useCallback(
(dataMask: DataMask) => onFilterSelectionChange(filter, dataMask), (dataMask: DataMask) => onFilterSelectionChange(filter, dataMask),
[filter, onFilterSelectionChange], [filter, onFilterSelectionChange],
); );
const setFocusedFilter = useCallback( const setFocusedFilter = useCallback(() => {
() => dispatchFocusAction(dispatch, id), // don't highlight charts in scope if filter was focused programmatically
[dispatch, id], if (outlinedFilterId !== id) {
); dispatchFocusAction(dispatch, id);
const unsetFocusedFilter = useCallback( }
() => dispatchFocusAction(dispatch), }, [dispatch, id, outlinedFilterId]);
[dispatch],
); const unsetFocusedFilter = useCallback(() => {
dispatchFocusAction(dispatch);
if (outlinedFilterId === id) {
dispatch(setDirectPathToChild([]));
}
}, [dispatch, id, outlinedFilterId]);
const setHoveredFilter = useCallback( const setHoveredFilter = useCallback(
() => dispatchHoverAction(dispatch, id), () => dispatchHoverAction(dispatch, id),
@@ -26,6 +26,7 @@ export interface FiltersDropdownContentProps {
filtersOutOfScope: (Filter | Divider)[]; filtersOutOfScope: (Filter | Divider)[];
renderer: (filter: Filter | Divider, index: number) => ReactNode; renderer: (filter: Filter | Divider, index: number) => ReactNode;
showCollapsePanel?: boolean; showCollapsePanel?: boolean;
forceRenderOutOfScope?: boolean;
} }
export const FiltersDropdownContent = ({ export const FiltersDropdownContent = ({
@@ -33,6 +34,7 @@ export const FiltersDropdownContent = ({
filtersOutOfScope, filtersOutOfScope,
renderer, renderer,
showCollapsePanel, showCollapsePanel,
forceRenderOutOfScope,
}: FiltersDropdownContentProps) => ( }: FiltersDropdownContentProps) => (
<div <div
css={(theme: SupersetTheme) => css={(theme: SupersetTheme) =>
@@ -47,6 +49,7 @@ export const FiltersDropdownContent = ({
<FiltersOutOfScopeCollapsible <FiltersOutOfScopeCollapsible
filtersOutOfScope={filtersOutOfScope} filtersOutOfScope={filtersOutOfScope}
renderer={renderer} renderer={renderer}
forceRender={forceRenderOutOfScope}
horizontalOverflow horizontalOverflow
/> />
)} )}
@@ -26,6 +26,7 @@ export interface FiltersOutOfScopeCollapsibleProps {
renderer: (filter: Filter | Divider, index: number) => ReactNode; renderer: (filter: Filter | Divider, index: number) => ReactNode;
hasTopMargin?: boolean; hasTopMargin?: boolean;
horizontalOverflow?: boolean; horizontalOverflow?: boolean;
forceRender?: boolean;
} }
export const FiltersOutOfScopeCollapsible = ({ export const FiltersOutOfScopeCollapsible = ({
@@ -33,6 +34,7 @@ export const FiltersOutOfScopeCollapsible = ({
renderer, renderer,
hasTopMargin, hasTopMargin,
horizontalOverflow, horizontalOverflow,
forceRender = false,
}: FiltersOutOfScopeCollapsibleProps) => ( }: FiltersOutOfScopeCollapsibleProps) => (
<AntdCollapse <AntdCollapse
ghost ghost
@@ -80,6 +82,7 @@ export const FiltersOutOfScopeCollapsible = ({
} }
> >
<AntdCollapse.Panel <AntdCollapse.Panel
forceRender={forceRender}
header={t('Filters out of scope (%d)', filtersOutOfScope.length)} header={t('Filters out of scope (%d)', filtersOutOfScope.length)}
key="1" key="1"
> >
@@ -93,7 +93,6 @@ const HorizontalFilterBar: React.FC<HorizontalBarProps> = ({
dataMaskSelected, dataMaskSelected,
filterValues, filterValues,
isInitialized, isInitialized,
focusedFilterId,
onSelectionChange, onSelectionChange,
}) => { }) => {
const hasFilters = filterValues.length > 0; const hasFilters = filterValues.length > 0;
@@ -124,7 +123,6 @@ const HorizontalFilterBar: React.FC<HorizontalBarProps> = ({
{hasFilters && ( {hasFilters && (
<FilterControls <FilterControls
dataMaskSelected={dataMaskSelected} dataMaskSelected={dataMaskSelected}
focusedFilterId={focusedFilterId}
onFilterSelectionChange={onSelectionChange} onFilterSelectionChange={onSelectionChange}
/> />
)} )}
@@ -141,7 +141,6 @@ const VerticalFilterBar: React.FC<VerticalBarProps> = ({
actions, actions,
canEdit, canEdit,
dataMaskSelected, dataMaskSelected,
focusedFilterId,
filtersOpen, filtersOpen,
filterValues, filterValues,
height, height,
@@ -258,7 +257,6 @@ const VerticalFilterBar: React.FC<VerticalBarProps> = ({
<FilterControlsWrapper> <FilterControlsWrapper>
<FilterControls <FilterControls
dataMaskSelected={dataMaskSelected} dataMaskSelected={dataMaskSelected}
focusedFilterId={focusedFilterId}
onFilterSelectionChange={onSelectionChange} onFilterSelectionChange={onSelectionChange}
/> />
</FilterControlsWrapper> </FilterControlsWrapper>
@@ -300,7 +298,6 @@ const VerticalFilterBar: React.FC<VerticalBarProps> = ({
<FilterControlsWrapper> <FilterControlsWrapper>
<FilterControls <FilterControls
dataMaskSelected={dataMaskSelected} dataMaskSelected={dataMaskSelected}
focusedFilterId={focusedFilterId}
onFilterSelectionChange={onSelectionChange} onFilterSelectionChange={onSelectionChange}
/> />
</FilterControlsWrapper> </FilterControlsWrapper>
@@ -111,7 +111,6 @@ const publishDataMask = debounce(
export const FilterBarScrollContext = createContext(false); export const FilterBarScrollContext = createContext(false);
const FilterBar: React.FC<FiltersBarProps> = ({ const FilterBar: React.FC<FiltersBarProps> = ({
focusedFilterId,
orientation = FilterBarOrientation.VERTICAL, orientation = FilterBarOrientation.VERTICAL,
verticalConfig, verticalConfig,
}) => { }) => {
@@ -254,7 +253,6 @@ const FilterBar: React.FC<FiltersBarProps> = ({
canEdit={canEdit} canEdit={canEdit}
dashboardId={dashboardId} dashboardId={dashboardId}
dataMaskSelected={dataMaskSelected} dataMaskSelected={dataMaskSelected}
focusedFilterId={focusedFilterId}
filterValues={filterValues} filterValues={filterValues}
isInitialized={isInitialized} isInitialized={isInitialized}
onSelectionChange={handleFilterSelectionChange} onSelectionChange={handleFilterSelectionChange}
@@ -264,7 +262,6 @@ const FilterBar: React.FC<FiltersBarProps> = ({
actions={actions} actions={actions}
canEdit={canEdit} canEdit={canEdit}
dataMaskSelected={dataMaskSelected} dataMaskSelected={dataMaskSelected}
focusedFilterId={focusedFilterId}
filtersOpen={verticalConfig.filtersOpen} filtersOpen={verticalConfig.filtersOpen}
filterValues={filterValues} filterValues={filterValues}
isInitialized={isInitialized} isInitialized={isInitialized}
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { ReactNode } from 'react';
import { import {
DataMask, DataMask,
DataMaskStateWithId, DataMaskStateWithId,
@@ -25,10 +27,9 @@ import {
import { FilterBarOrientation } from 'src/dashboard/types'; import { FilterBarOrientation } from 'src/dashboard/types';
interface CommonFiltersBarProps { interface CommonFiltersBarProps {
actions: React.ReactNode; actions: ReactNode;
canEdit: boolean; canEdit: boolean;
dataMaskSelected: DataMaskStateWithId; dataMaskSelected: DataMaskStateWithId;
focusedFilterId?: string;
filterValues: (Filter | Divider)[]; filterValues: (Filter | Divider)[];
isInitialized: boolean; isInitialized: boolean;
onSelectionChange: ( onSelectionChange: (
@@ -45,8 +46,7 @@ interface VerticalBarConfig {
width: number; width: number;
} }
export interface FiltersBarProps export interface FiltersBarProps {
extends Pick<CommonFiltersBarProps, 'focusedFilterId'> {
orientation: FilterBarOrientation; orientation: FilterBarOrientation;
verticalConfig?: VerticalBarConfig; verticalConfig?: VerticalBarConfig;
} }
@@ -32,7 +32,6 @@ import FilterDivider from './FilterControls/FilterDivider';
export const useFilterControlFactory = ( export const useFilterControlFactory = (
dataMaskSelected: DataMaskStateWithId, dataMaskSelected: DataMaskStateWithId,
focusedFilterId: string | undefined,
onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void, onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void,
) => { ) => {
const filters = useFilters(); const filters = useFilters();
@@ -67,7 +66,6 @@ export const useFilterControlFactory = (
<FilterControl <FilterControl
dataMaskSelected={dataMaskSelected} dataMaskSelected={dataMaskSelected}
filter={filter} filter={filter}
focusedFilterId={focusedFilterId}
onFilterSelectionChange={onFilterSelectionChange} onFilterSelectionChange={onFilterSelectionChange}
inView={false} inView={false}
orientation={filterBarOrientation} orientation={filterBarOrientation}
@@ -75,12 +73,7 @@ export const useFilterControlFactory = (
/> />
); );
}, },
[ [filtersWithValues, dataMaskSelected, onFilterSelectionChange],
filtersWithValues,
dataMaskSelected,
focusedFilterId,
onFilterSelectionChange,
],
); );
return { filterControlFactory, filtersWithValues }; return { filterControlFactory, filtersWithValues };
@@ -0,0 +1,34 @@
/**
* 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 { useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
export const useFilterOutlined = () =>
useSelector<RootState, { outlinedFilterId: string; lastUpdated: number }>(
state => ({
outlinedFilterId: (
getChartAndLabelComponentIdFromPath(
state.dashboardState.directPathToChild || [],
) as Record<string, string>
)?.native_filter,
lastUpdated: state.dashboardState.directPathLastUpdated,
}),
);
@@ -21,7 +21,7 @@ import { useDispatch } from 'react-redux';
import { css, t, useTheme } from '@superset-ui/core'; import { css, t, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { useTruncation } from 'src/hooks/useTruncation'; import { useTruncation } from 'src/hooks/useTruncation';
import { setFocusedNativeFilter } from 'src/dashboard/actions/nativeFilters'; import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState';
import { import {
DependencyItem, DependencyItem,
Row, Row,
@@ -40,7 +40,7 @@ const DependencyValue = ({
}: DependencyValueProps) => { }: DependencyValueProps) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
dispatch(setFocusedNativeFilter(dependency.id)); dispatch(setDirectPathToChild([dependency.id]));
}, [dependency.id, dispatch]); }, [dependency.id, dispatch]);
return ( return (
<span> <span>
@@ -23,7 +23,7 @@ import { Filter, NativeFilterType } from '@superset-ui/core';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library'; import { render, screen } from 'spec/helpers/testing-library';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { SET_FOCUSED_NATIVE_FILTER } from 'src/dashboard/actions/nativeFilters'; import { SET_DIRECT_PATH } from 'src/dashboard/actions/dashboardState';
import { FilterCardContent } from './FilterCardContent'; import { FilterCardContent } from './FilterCardContent';
const baseInitialState = { const baseInitialState = {
@@ -304,8 +304,8 @@ test('focus filter on filter card dependency click', () => {
userEvent.click(screen.getByText('Native filter 2')); userEvent.click(screen.getByText('Native filter 2'));
expect(dummyDispatch).toHaveBeenCalledWith({ expect(dummyDispatch).toHaveBeenCalledWith({
type: SET_FOCUSED_NATIVE_FILTER, type: SET_DIRECT_PATH,
id: 'NATIVE_FILTER-2', path: ['NATIVE_FILTER-2'],
}); });
}); });
@@ -17,6 +17,7 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library'; import { render, screen } from 'spec/helpers/testing-library';
import { import {
DatasourceType, DatasourceType,
@@ -104,5 +105,43 @@ describe('ControlPanelsContainer', () => {
expect( expect(
await screen.findAllByTestId('collapsible-control-panel-header'), await screen.findAllByTestId('collapsible-control-panel-header'),
).toHaveLength(4); ).toHaveLength(4);
expect(screen.getByRole('tab', { name: /customize/i })).toBeInTheDocument();
userEvent.click(screen.getByRole('tab', { name: /customize/i }));
expect(
await screen.findAllByTestId('collapsible-control-panel-header'),
).toHaveLength(5);
});
test('renders ControlPanelSections no Customize Tab', async () => {
getChartControlPanelRegistry().registerValue('table', {
controlPanelSections: [
{
label: t('GROUP BY'),
description: t(
'Use this section if you want a query that aggregates',
),
expanded: true,
controlSetRows: [
['groupby'],
['metrics'],
['percent_metrics'],
['timeseries_limit_metric', 'row_limit'],
['include_time', 'order_desc'],
],
},
{
label: t('Options'),
expanded: true,
controlSetRows: [],
},
],
});
render(<ControlPanelsContainer {...getDefaultProps()} />, {
useRedux: true,
});
expect(screen.queryByText(/customize/i)).not.toBeInTheDocument();
expect(
await screen.findAllByTestId('collapsible-control-panel-header'),
).toHaveLength(2);
}); });
}); });
@@ -235,7 +235,7 @@ function getState(
) )
) { ) {
querySections.push(section); querySections.push(section);
} else { } else if (section.controlSetRows.length > 0) {
customizeSections.push(section); customizeSections.push(section);
} }
}); });
@@ -1385,6 +1385,69 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
</StyledBtns> </StyledBtns>
); );
const renderDatabaseConnectionForm = () => (
<>
<DatabaseConnectionForm
isEditMode={isEditMode}
db={db as DatabaseObject}
sslForced={sslForced}
dbModel={dbModel}
onAddTableCatalog={() => {
setDB({ type: ActionType.addTableCatalogSheet });
}}
onQueryChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.queryChange, {
name: target.name,
value: target.value,
})
}
onExtraInputChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.extraInputChange, {
name: target.name,
value: target.value,
})
}
onRemoveTableCatalog={(idx: number) => {
setDB({
type: ActionType.removeTableCatalogSheet,
payload: { indexToDelete: idx },
});
}}
onParametersChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.parametersChange, {
type: target.type,
name: target.name,
checked: target.checked,
value: target.value,
})
}
onChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.textChange, {
name: target.name,
value: target.value,
})
}
getValidation={() => getValidation(db)}
validationErrors={validationErrors}
getPlaceholder={getPlaceholder}
/>
<SSHTunnelContainer>
<SSHTunnelSwitchComponent
isEditMode={isEditMode}
dbFetched={dbFetched}
disableSSHTunnelingForEngine={disableSSHTunnelingForEngine}
useSSHTunneling={useSSHTunneling}
setUseSSHTunneling={setUseSSHTunneling}
setDB={setDB}
isSSHTunneling={isSSHTunneling}
/>
</SSHTunnelContainer>
{useSSHTunneling && (
<SSHTunnelContainer>{renderSSHTunnelForm()}</SSHTunnelContainer>
)}
</>
);
const renderFinishState = () => { const renderFinishState = () => {
if (!editNewDb) { if (!editNewDb) {
return ( return (
@@ -1421,51 +1484,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
/> />
); );
} }
return ( return renderDatabaseConnectionForm();
<DatabaseConnectionForm
isEditMode
sslForced={sslForced}
dbModel={dbModel}
db={db as DatabaseObject}
onParametersChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.parametersChange, {
type: target.type,
name: target.name,
checked: target.checked,
value: target.value,
})
}
onExtraInputChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.extraInputChange, {
name: target.name,
value: target.value,
})
}
onChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.textChange, {
name: target.name,
value: target.value,
})
}
onQueryChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.queryChange, {
name: target.name,
value: target.value,
})
}
onAddTableCatalog={() =>
setDB({ type: ActionType.addTableCatalogSheet })
}
onRemoveTableCatalog={(idx: number) =>
setDB({
type: ActionType.removeTableCatalogSheet,
payload: { indexToDelete: idx },
})
}
getValidation={() => getValidation(db)}
validationErrors={validationErrors}
/>
);
}; };
if (fileList.length > 0 && (alreadyExists.length || passwordFields.length)) { if (fileList.length > 0 && (alreadyExists.length || passwordFields.length)) {
@@ -1604,49 +1623,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
)} )}
</StyledAlignment> </StyledAlignment>
) : ( ) : (
<DatabaseConnectionForm renderDatabaseConnectionForm()
isEditMode
sslForced={sslForced}
dbModel={dbModel}
db={db as DatabaseObject}
onParametersChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.parametersChange, {
type: target.type,
name: target.name,
checked: target.checked,
value: target.value,
})
}
onExtraInputChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.extraInputChange, {
name: target.name,
value: target.value,
})
}
onChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.textChange, {
name: target.name,
value: target.value,
})
}
onQueryChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.queryChange, {
name: target.name,
value: target.value,
})
}
onAddTableCatalog={() =>
setDB({ type: ActionType.addTableCatalogSheet })
}
onRemoveTableCatalog={(idx: number) =>
setDB({
type: ActionType.removeTableCatalogSheet,
payload: { indexToDelete: idx },
})
}
getValidation={() => getValidation(db)}
validationErrors={validationErrors}
/>
)} )}
{!isEditMode && ( {!isEditMode && (
<StyledAlertMargin> <StyledAlertMargin>
@@ -1796,73 +1773,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
dbModel={dbModel} dbModel={dbModel}
/> />
{hasAlert && renderStepTwoAlert()} {hasAlert && renderStepTwoAlert()}
<DatabaseConnectionForm {renderDatabaseConnectionForm()}
db={db}
sslForced={sslForced}
dbModel={dbModel}
onAddTableCatalog={() => {
setDB({ type: ActionType.addTableCatalogSheet });
}}
onQueryChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.queryChange, {
name: target.name,
value: target.value,
})
}
onExtraInputChange={({
target,
}: {
target: HTMLInputElement;
}) =>
onChange(ActionType.extraInputChange, {
name: target.name,
value: target.value,
})
}
onRemoveTableCatalog={(idx: number) => {
setDB({
type: ActionType.removeTableCatalogSheet,
payload: { indexToDelete: idx },
});
}}
onParametersChange={({
target,
}: {
target: HTMLInputElement;
}) =>
onChange(ActionType.parametersChange, {
type: target.type,
name: target.name,
checked: target.checked,
value: target.value,
})
}
onChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.textChange, {
name: target.name,
value: target.value,
})
}
getValidation={() => getValidation(db)}
validationErrors={validationErrors}
getPlaceholder={getPlaceholder}
/>
<SSHTunnelContainer>
<SSHTunnelSwitchComponent
isEditMode={isEditMode}
dbFetched={dbFetched}
disableSSHTunnelingForEngine={disableSSHTunnelingForEngine}
useSSHTunneling={useSSHTunneling}
setUseSSHTunneling={setUseSSHTunneling}
setDB={setDB}
isSSHTunneling={isSSHTunneling}
/>
</SSHTunnelContainer>
{useSSHTunneling && (
<SSHTunnelContainer>
{renderSSHTunnelForm()}
</SSHTunnelContainer>
)}
<div css={(theme: SupersetTheme) => infoTooltip(theme)}> <div css={(theme: SupersetTheme) => infoTooltip(theme)}>
{dbModel.engine !== Engines.GSheet && ( {dbModel.engine !== Engines.GSheet && (
<> <>
+9 -17
View File
@@ -18,13 +18,15 @@ from typing import Any
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from sqlalchemy.orm import aliased
from sqlalchemy.orm.query import Query from sqlalchemy.orm.query import Query
from superset import db, security_manager from superset import security_manager
from superset.connectors.sqla import models from superset.connectors.sqla import models
from superset.connectors.sqla.models import SqlaTable from superset.connectors.sqla.models import SqlaTable
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.utils.core import get_user_id from superset.utils.core import get_user_id
from superset.utils.filters import get_dataset_access_filters
from superset.views.base import BaseFilter from superset.views.base import BaseFilter
from superset.views.base_api import BaseFavoriteFilter from superset.views.base_api import BaseFavoriteFilter
@@ -77,23 +79,13 @@ class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods
def apply(self, query: Query, value: Any) -> Query: def apply(self, query: Query, value: Any) -> Query:
if security_manager.can_access_all_datasources(): if security_manager.can_access_all_datasources():
return query return query
perms = security_manager.user_view_menu_names("datasource_access")
schema_perms = security_manager.user_view_menu_names("schema_access") table_alias = aliased(SqlaTable)
owner_ids_query = ( query = query.join(table_alias, self.model.datasource_id == table_alias.id)
db.session.query(models.SqlaTable.id) query = query.join(
.join(models.SqlaTable.owners) models.Database, table_alias.database_id == models.Database.id
.filter(
security_manager.user_model.id
== security_manager.user_model.get_user_id()
)
)
return query.filter(
or_(
self.model.perm.in_(perms),
self.model.schema_perm.in_(schema_perms),
models.SqlaTable.id.in_(owner_ids_query),
)
) )
return query.filter(get_dataset_access_filters(self.model))
class ChartHasCreatedByFilter(BaseFilter): # pylint: disable=too-few-public-methods class ChartHasCreatedByFilter(BaseFilter): # pylint: disable=too-few-public-methods
+12 -14
View File
@@ -17,7 +17,7 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
from flask_babel import _ from flask_babel import _
@@ -32,7 +32,6 @@ from superset.utils.core import (
ExtraFiltersReasonType, ExtraFiltersReasonType,
get_column_name, get_column_name,
get_time_filter_status, get_time_filter_status,
is_adhoc_column,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -102,7 +101,6 @@ def _get_full(
datasource = _get_datasource(query_context, query_obj) datasource = _get_datasource(query_context, query_obj)
result_type = query_obj.result_type or query_context.result_type result_type = query_obj.result_type or query_context.result_type
payload = query_context.get_df_payload(query_obj, force_cached=force_cached) payload = query_context.get_df_payload(query_obj, force_cached=force_cached)
applied_template_filters = payload.get("applied_template_filters", [])
df = payload["df"] df = payload["df"]
status = payload["status"] status = payload["status"]
if status != QueryStatus.FAILED: if status != QueryStatus.FAILED:
@@ -113,23 +111,23 @@ def _get_full(
payload["result_format"] = query_context.result_format payload["result_format"] = query_context.result_format
del payload["df"] del payload["df"]
filters = query_obj.filter
filter_columns = cast(List[str], [flt.get("col") for flt in filters])
columns = set(datasource.column_names)
applied_time_columns, rejected_time_columns = get_time_filter_status( applied_time_columns, rejected_time_columns = get_time_filter_status(
datasource, query_obj.applied_time_extras datasource, query_obj.applied_time_extras
) )
applied_filter_columns = payload.get("applied_filter_columns", [])
rejected_filter_columns = payload.get("rejected_filter_columns", [])
del payload["applied_filter_columns"]
del payload["rejected_filter_columns"]
payload["applied_filters"] = [ payload["applied_filters"] = [
{"column": get_column_name(col)} {"column": get_column_name(col)} for col in applied_filter_columns
for col in filter_columns
if is_adhoc_column(col) or col in columns or col in applied_template_filters
] + applied_time_columns ] + applied_time_columns
payload["rejected_filters"] = [ payload["rejected_filters"] = [
{"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE, "column": col} {
for col in filter_columns "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
if not is_adhoc_column(col) "column": get_column_name(col),
and col not in columns }
and col not in applied_template_filters for col in rejected_filter_columns
] + rejected_time_columns ] + rejected_time_columns
if result_type == ChartDataResultType.RESULTS and status != QueryStatus.FAILED: if result_type == ChartDataResultType.RESULTS and status != QueryStatus.FAILED:
@@ -165,6 +165,8 @@ class QueryContextProcessor:
"cache_timeout": self.get_cache_timeout(), "cache_timeout": self.get_cache_timeout(),
"df": cache.df, "df": cache.df,
"applied_template_filters": cache.applied_template_filters, "applied_template_filters": cache.applied_template_filters,
"applied_filter_columns": cache.applied_filter_columns,
"rejected_filter_columns": cache.rejected_filter_columns,
"annotation_data": cache.annotation_data, "annotation_data": cache.annotation_data,
"error": cache.error_message, "error": cache.error_message,
"is_cached": cache.is_cached, "is_cached": cache.is_cached,
@@ -29,6 +29,7 @@ from superset.exceptions import CacheLoadError
from superset.extensions import cache_manager from superset.extensions import cache_manager
from superset.models.helpers import QueryResult from superset.models.helpers import QueryResult
from superset.stats_logger import BaseStatsLogger from superset.stats_logger import BaseStatsLogger
from superset.superset_typing import Column
from superset.utils.cache import set_and_log_cache from superset.utils.cache import set_and_log_cache
from superset.utils.core import error_msg_from_exception, get_stacktrace from superset.utils.core import error_msg_from_exception, get_stacktrace
@@ -54,6 +55,8 @@ class QueryCacheManager:
query: str = "", query: str = "",
annotation_data: Optional[Dict[str, Any]] = None, annotation_data: Optional[Dict[str, Any]] = None,
applied_template_filters: Optional[List[str]] = None, applied_template_filters: Optional[List[str]] = None,
applied_filter_columns: Optional[List[Column]] = None,
rejected_filter_columns: Optional[List[Column]] = None,
status: Optional[str] = None, status: Optional[str] = None,
error_message: Optional[str] = None, error_message: Optional[str] = None,
is_loaded: bool = False, is_loaded: bool = False,
@@ -66,6 +69,8 @@ class QueryCacheManager:
self.query = query self.query = query
self.annotation_data = {} if annotation_data is None else annotation_data self.annotation_data = {} if annotation_data is None else annotation_data
self.applied_template_filters = applied_template_filters or [] self.applied_template_filters = applied_template_filters or []
self.applied_filter_columns = applied_filter_columns or []
self.rejected_filter_columns = rejected_filter_columns or []
self.status = status self.status = status
self.error_message = error_message self.error_message = error_message
@@ -93,6 +98,8 @@ class QueryCacheManager:
self.status = query_result.status self.status = query_result.status
self.query = query_result.query self.query = query_result.query
self.applied_template_filters = query_result.applied_template_filters self.applied_template_filters = query_result.applied_template_filters
self.applied_filter_columns = query_result.applied_filter_columns
self.rejected_filter_columns = query_result.rejected_filter_columns
self.error_message = query_result.error_message self.error_message = query_result.error_message
self.df = query_result.df self.df = query_result.df
self.annotation_data = {} if annotation_data is None else annotation_data self.annotation_data = {} if annotation_data is None else annotation_data
@@ -107,6 +114,8 @@ class QueryCacheManager:
"df": self.df, "df": self.df,
"query": self.query, "query": self.query,
"applied_template_filters": self.applied_template_filters, "applied_template_filters": self.applied_template_filters,
"applied_filter_columns": self.applied_filter_columns,
"rejected_filter_columns": self.rejected_filter_columns,
"annotation_data": self.annotation_data, "annotation_data": self.annotation_data,
} }
if self.is_loaded and key and self.status != QueryStatus.FAILED: if self.is_loaded and key and self.status != QueryStatus.FAILED:
@@ -150,6 +159,12 @@ class QueryCacheManager:
query_cache.applied_template_filters = cache_value.get( query_cache.applied_template_filters = cache_value.get(
"applied_template_filters", [] "applied_template_filters", []
) )
query_cache.applied_filter_columns = cache_value.get(
"applied_filter_columns", []
)
query_cache.rejected_filter_columns = cache_value.get(
"rejected_filter_columns", []
)
query_cache.status = QueryStatus.SUCCESS query_cache.status = QueryStatus.SUCCESS
query_cache.is_loaded = True query_cache.is_loaded = True
query_cache.is_cached = cache_value is not None query_cache.is_cached = cache_value is not None
+5 -5
View File
@@ -188,10 +188,11 @@ CUSTOM_SECURITY_MANAGER = None
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
# --------------------------------------------------------- # ---------------------------------------------------------
# Your App secret key. Make sure you override it on superset_config.py. # Your App secret key. Make sure you override it on superset_config.py
# or use `SUPERSET_SECRET_KEY` environment variable.
# Use a strong complex alphanumeric string and use a tool to help you generate # Use a strong complex alphanumeric string and use a tool to help you generate
# a sufficiently random sequence, ex: openssl rand -base64 42" # a sufficiently random sequence, ex: openssl rand -base64 42"
SECRET_KEY = CHANGE_ME_SECRET_KEY SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY") or CHANGE_ME_SECRET_KEY
# The SQLAlchemy connection string. # The SQLAlchemy connection string.
SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(DATA_DIR, "superset.db") SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(DATA_DIR, "superset.db")
@@ -1301,9 +1302,8 @@ WEBDRIVER_AUTH_FUNC = None
WEBDRIVER_CONFIGURATION: Dict[Any, Any] = {"service_log_path": "/dev/null"} WEBDRIVER_CONFIGURATION: Dict[Any, Any] = {"service_log_path": "/dev/null"}
# Additional args to be passed as arguments to the config object # Additional args to be passed as arguments to the config object
# Note: these options are Chrome-specific. For FF, these should # Note: If using Chrome, you'll want to add the "--marionette" arg.
# only include the "--headless" arg WEBDRIVER_OPTION_ARGS = ["--headless"]
WEBDRIVER_OPTION_ARGS = ["--headless", "--marionette"]
# The base URL to query for accessing the user interface # The base URL to query for accessing the user interface
WEBDRIVER_BASEURL = "http://0.0.0.0:8080/" WEBDRIVER_BASEURL = "http://0.0.0.0:8080/"
+48 -9
View File
@@ -99,9 +99,11 @@ from superset.datasets.models import Dataset as NewDataset
from superset.db_engine_specs.base import BaseEngineSpec, TimestampExpression from superset.db_engine_specs.base import BaseEngineSpec, TimestampExpression
from superset.exceptions import ( from superset.exceptions import (
AdvancedDataTypeResponseError, AdvancedDataTypeResponseError,
ColumnNotFoundException,
DatasetInvalidPermissionEvaluationException, DatasetInvalidPermissionEvaluationException,
QueryClauseValidationException, QueryClauseValidationException,
QueryObjectValidationError, QueryObjectValidationError,
SupersetGenericDBErrorException,
SupersetSecurityException, SupersetSecurityException,
) )
from superset.extensions import feature_flag_manager from superset.extensions import feature_flag_manager
@@ -150,6 +152,8 @@ ADDITIVE_METRIC_TYPES_LOWER = {op.lower() for op in ADDITIVE_METRIC_TYPES}
class SqlaQuery(NamedTuple): class SqlaQuery(NamedTuple):
applied_template_filters: List[str] applied_template_filters: List[str]
applied_filter_columns: List[ColumnTyping]
rejected_filter_columns: List[ColumnTyping]
cte: Optional[str] cte: Optional[str]
extra_cache_keys: List[Any] extra_cache_keys: List[Any]
labels_expected: List[str] labels_expected: List[str]
@@ -159,6 +163,8 @@ class SqlaQuery(NamedTuple):
class QueryStringExtended(NamedTuple): class QueryStringExtended(NamedTuple):
applied_template_filters: Optional[List[str]] applied_template_filters: Optional[List[str]]
applied_filter_columns: List[ColumnTyping]
rejected_filter_columns: List[ColumnTyping]
labels_expected: List[str] labels_expected: List[str]
prequeries: List[str] prequeries: List[str]
sql: str sql: str
@@ -882,6 +888,8 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
sql = self.mutate_query_from_config(sql) sql = self.mutate_query_from_config(sql)
return QueryStringExtended( return QueryStringExtended(
applied_template_filters=sqlaq.applied_template_filters, applied_template_filters=sqlaq.applied_template_filters,
applied_filter_columns=sqlaq.applied_filter_columns,
rejected_filter_columns=sqlaq.rejected_filter_columns,
labels_expected=sqlaq.labels_expected, labels_expected=sqlaq.labels_expected,
prequeries=sqlaq.prequeries, prequeries=sqlaq.prequeries,
sql=sql, sql=sql,
@@ -1024,13 +1032,16 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
) )
is_dttm = col_in_metadata.is_temporal is_dttm = col_in_metadata.is_temporal
else: else:
sqla_column = literal_column(expression) try:
# probe adhoc column type sqla_column = literal_column(expression)
tbl, _ = self.get_from_clause(template_processor) # probe adhoc column type
qry = sa.select([sqla_column]).limit(1).select_from(tbl) tbl, _ = self.get_from_clause(template_processor)
sql = self.database.compile_sqla_query(qry) qry = sa.select([sqla_column]).limit(1).select_from(tbl)
col_desc = get_columns_description(self.database, sql) sql = self.database.compile_sqla_query(qry)
is_dttm = col_desc[0]["is_dttm"] col_desc = get_columns_description(self.database, sql)
is_dttm = col_desc[0]["is_dttm"]
except SupersetGenericDBErrorException as ex:
raise ColumnNotFoundException(message=str(ex)) from ex
if ( if (
is_dttm is_dttm
@@ -1185,6 +1196,8 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
} }
columns = columns or [] columns = columns or []
groupby = groupby or [] groupby = groupby or []
rejected_adhoc_filters_columns: List[Union[str, ColumnTyping]] = []
applied_adhoc_filters_columns: List[Union[str, ColumnTyping]] = []
series_column_names = utils.get_column_names(series_columns or []) series_column_names = utils.get_column_names(series_columns or [])
# deprecated, to be removed in 2.0 # deprecated, to be removed in 2.0
if is_timeseries and timeseries_limit: if is_timeseries and timeseries_limit:
@@ -1443,9 +1456,14 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
if flt_col == utils.DTTM_ALIAS and is_timeseries and dttm_col: if flt_col == utils.DTTM_ALIAS and is_timeseries and dttm_col:
col_obj = dttm_col col_obj = dttm_col
elif is_adhoc_column(flt_col): elif is_adhoc_column(flt_col):
sqla_col = self.adhoc_column_to_sqla(flt_col) try:
sqla_col = self.adhoc_column_to_sqla(flt_col)
applied_adhoc_filters_columns.append(flt_col)
except ColumnNotFoundException:
rejected_adhoc_filters_columns.append(flt_col)
continue
else: else:
col_obj = columns_by_name.get(flt_col) col_obj = columns_by_name.get(cast(str, flt_col))
filter_grain = flt.get("grain") filter_grain = flt.get("grain")
if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"): if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"):
@@ -1770,8 +1788,27 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
qry = select([col]).select_from(qry.alias("rowcount_qry")) qry = select([col]).select_from(qry.alias("rowcount_qry"))
labels_expected = [label] labels_expected = [label]
filter_columns = [flt.get("col") for flt in filter] if filter else []
rejected_filter_columns = [
col
for col in filter_columns
if col
and not is_adhoc_column(col)
and col not in self.column_names
and col not in applied_template_filters
] + rejected_adhoc_filters_columns
applied_filter_columns = [
col
for col in filter_columns
if col
and not is_adhoc_column(col)
and (col in self.column_names or col in applied_template_filters)
] + applied_adhoc_filters_columns
return SqlaQuery( return SqlaQuery(
applied_template_filters=applied_template_filters, applied_template_filters=applied_template_filters,
rejected_filter_columns=rejected_filter_columns,
applied_filter_columns=applied_filter_columns,
cte=cte, cte=cte,
extra_cache_keys=extra_cache_keys, extra_cache_keys=extra_cache_keys,
labels_expected=labels_expected, labels_expected=labels_expected,
@@ -1910,6 +1947,8 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
return QueryResult( return QueryResult(
applied_template_filters=query_str_ext.applied_template_filters, applied_template_filters=query_str_ext.applied_template_filters,
applied_filter_columns=query_str_ext.applied_filter_columns,
rejected_filter_columns=query_str_ext.rejected_filter_columns,
status=status, status=status,
df=df, df=df,
duration=datetime.now() - qry_start_dttm, duration=datetime.now() - qry_start_dttm,
+4 -3
View File
@@ -17,6 +17,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from functools import lru_cache
from typing import ( from typing import (
Any, Any,
Callable, Callable,
@@ -40,6 +41,7 @@ from sqlalchemy.orm import Session
from sqlalchemy.orm.exc import ObjectDeletedError from sqlalchemy.orm.exc import ObjectDeletedError
from sqlalchemy.sql.type_api import TypeEngine from sqlalchemy.sql.type_api import TypeEngine
from superset.constants import LRU_CACHE_MAX_SIZE
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import ( from superset.exceptions import (
SupersetGenericDBErrorException, SupersetGenericDBErrorException,
@@ -49,7 +51,6 @@ from superset.models.core import Database
from superset.result_set import SupersetResultSet from superset.result_set import SupersetResultSet
from superset.sql_parse import has_table_query, insert_rls, ParsedQuery from superset.sql_parse import has_table_query, insert_rls, ParsedQuery
from superset.superset_typing import ResultSetColumnType from superset.superset_typing import ResultSetColumnType
from superset.utils.memoized import memoized
if TYPE_CHECKING: if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlaTable from superset.connectors.sqla.models import SqlaTable
@@ -200,12 +201,12 @@ def validate_adhoc_subquery(
return ";\n".join(str(statement) for statement in statements) return ";\n".join(str(statement) for statement in statements)
@memoized @lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
def get_dialect_name(drivername: str) -> str: def get_dialect_name(drivername: str) -> str:
return SqlaURL.create(drivername).get_dialect().name return SqlaURL.create(drivername).get_dialect().name
@memoized @lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
def get_identifier_quoter(drivername: str) -> Dict[str, Callable[[str], str]]: def get_identifier_quoter(drivername: str) -> Dict[str, Callable[[str], str]]:
return SqlaURL.create(drivername).get_dialect()().identifier_preparer.quote return SqlaURL.create(drivername).get_dialect()().identifier_preparer.quote
+2
View File
@@ -37,6 +37,8 @@ NO_TIME_RANGE = "No filter"
QUERY_CANCEL_KEY = "cancel_query" QUERY_CANCEL_KEY = "cancel_query"
QUERY_EARLY_CANCEL_KEY = "early_cancel_query" QUERY_EARLY_CANCEL_KEY = "early_cancel_query"
LRU_CACHE_MAX_SIZE = 256
class RouteMethod: # pylint: disable=too-few-public-methods class RouteMethod: # pylint: disable=too-few-public-methods
""" """
+2 -2
View File
@@ -65,9 +65,9 @@ class BaseDAO:
query = cls.base_filter( # pylint: disable=not-callable query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model cls.id_column_name, data_model
).apply(query, None) ).apply(query, None)
id_filter = {cls.id_column_name: model_id} id_column = getattr(cls.model_cls, cls.id_column_name)
try: try:
return query.filter_by(**id_filter).one_or_none() return query.filter(id_column == model_id).one_or_none()
except StatementError: except StatementError:
# can happen if int is passed instead of a string or similar # can happen if int is passed instead of a string or similar
return None return None
+7 -7
View File
@@ -24,12 +24,14 @@ from sqlalchemy import and_, or_
from sqlalchemy.orm.query import Query from sqlalchemy.orm.query import Query
from superset import db, is_feature_enabled, security_manager from superset import db, is_feature_enabled, security_manager
from superset.models.core import FavStar from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database, FavStar
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.models.embedded_dashboard import EmbeddedDashboard from superset.models.embedded_dashboard import EmbeddedDashboard
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.security.guest_token import GuestTokenResourceType, GuestUser from superset.security.guest_token import GuestTokenResourceType, GuestUser
from superset.utils.core import get_user_id from superset.utils.core import get_user_id
from superset.utils.filters import get_dataset_access_filters
from superset.views.base import BaseFilter from superset.views.base import BaseFilter
from superset.views.base_api import BaseFavoriteFilter from superset.views.base_api import BaseFavoriteFilter
@@ -101,9 +103,6 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
if security_manager.is_admin(): if security_manager.is_admin():
return query return query
datasource_perms = security_manager.user_view_menu_names("datasource_access")
schema_perms = security_manager.user_view_menu_names("schema_access")
is_rbac_disabled_filter = [] is_rbac_disabled_filter = []
dashboard_has_roles = Dashboard.roles.any() dashboard_has_roles = Dashboard.roles.any()
if is_feature_enabled("DASHBOARD_RBAC"): if is_feature_enabled("DASHBOARD_RBAC"):
@@ -112,13 +111,14 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
datasource_perm_query = ( datasource_perm_query = (
db.session.query(Dashboard.id) db.session.query(Dashboard.id)
.join(Dashboard.slices, isouter=True) .join(Dashboard.slices, isouter=True)
.join(SqlaTable, Slice.datasource_id == SqlaTable.id)
.join(Database, SqlaTable.database_id == Database.id)
.filter( .filter(
and_( and_(
Dashboard.published.is_(True), Dashboard.published.is_(True),
*is_rbac_disabled_filter, *is_rbac_disabled_filter,
or_( get_dataset_access_filters(
Slice.perm.in_(datasource_perms), Slice,
Slice.schema_perm.in_(schema_perms),
security_manager.can_access_all_datasources(), security_manager.can_access_all_datasources(),
), ),
) )
+15 -1
View File
@@ -72,7 +72,6 @@ from superset.utils.hashing import md5_sha_from_str
from superset.utils.network import is_hostname_valid, is_port_open from superset.utils.network import is_hostname_valid, is_port_open
if TYPE_CHECKING: if TYPE_CHECKING:
# prevent circular imports
from superset.connectors.sqla.models import TableColumn from superset.connectors.sqla.models import TableColumn
from superset.models.core import Database from superset.models.core import Database
from superset.models.sql_lab import Query from superset.models.sql_lab import Query
@@ -355,6 +354,8 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
# This set will give the keywords for data limit statements # This set will give the keywords for data limit statements
# to consider for the engines with TOP SQL parsing # to consider for the engines with TOP SQL parsing
top_keywords: Set[str] = {"TOP"} top_keywords: Set[str] = {"TOP"}
# A set of disallowed connection query parameters
disallow_uri_query_params: Set[str] = set()
force_column_alias_quotes = False force_column_alias_quotes = False
arraysize = 0 arraysize = 0
@@ -1725,6 +1726,19 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
"disable_ssh_tunneling": cls.disable_ssh_tunneling, "disable_ssh_tunneling": cls.disable_ssh_tunneling,
} }
@classmethod
def validate_database_uri(cls, sqlalchemy_uri: URL) -> None:
"""
Validates a database SQLAlchemy URI per engine spec.
Use this to implement a final validation for unwanted connection configuration
:param sqlalchemy_uri:
"""
if existing_disallowed := cls.disallow_uri_query_params.intersection(
sqlalchemy_uri.query
):
raise ValueError(f"Forbidden query parameter(s): {existing_disallowed}")
# schema for adding a database by providing parameters instead of the # schema for adding a database by providing parameters instead of the
# full SQLAlchemy URI # full SQLAlchemy URI
+295 -24
View File
@@ -14,29 +14,43 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
import logging from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
import logging
import re
from datetime import datetime
from typing import Any, cast, Dict, List, Optional, Type, TYPE_CHECKING
from flask import current_app
from flask_babel import gettext as __
from marshmallow import fields, Schema
from marshmallow.validate import Range
from sqlalchemy import types from sqlalchemy import types
from sqlalchemy.engine.url import URL
from urllib3.exceptions import NewConnectionError from urllib3.exceptions import NewConnectionError
from superset.db_engine_specs.base import BaseEngineSpec from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import (
BaseEngineSpec,
BasicParametersMixin,
BasicParametersType,
BasicPropertiesType,
)
from superset.db_engine_specs.exceptions import SupersetDBAPIDatabaseError from superset.db_engine_specs.exceptions import SupersetDBAPIDatabaseError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.extensions import cache_manager from superset.extensions import cache_manager
from superset.utils.core import GenericDataType
from superset.utils.hashing import md5_sha_from_str
from superset.utils.network import is_hostname_valid, is_port_open
if TYPE_CHECKING: if TYPE_CHECKING:
# prevent circular imports
from superset.models.core import Database from superset.models.core import Database
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ClickHouseEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method class ClickHouseBaseEngineSpec(BaseEngineSpec):
"""Dialect for ClickHouse analytical DB.""" """Shared engine spec for ClickHouse."""
engine = "clickhouse"
engine_name = "ClickHouse"
time_secondary_columns = True time_secondary_columns = True
time_groupby_inline = True time_groupby_inline = True
@@ -56,8 +70,78 @@ class ClickHouseEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
"P1Y": "toStartOfYear(toDateTime({col}))", "P1Y": "toStartOfYear(toDateTime({col}))",
} }
_show_functions_column = "name" column_type_mappings = (
(
re.compile(r".*Enum.*", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
(
re.compile(r".*Array.*", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
(
re.compile(r".*UUID.*", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
(
re.compile(r".*Bool.*", re.IGNORECASE),
types.Boolean(),
GenericDataType.BOOLEAN,
),
(
re.compile(r".*String.*", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
(
re.compile(r".*Int\d+.*", re.IGNORECASE),
types.INTEGER(),
GenericDataType.NUMERIC,
),
(
re.compile(r".*Decimal.*", re.IGNORECASE),
types.DECIMAL(),
GenericDataType.NUMERIC,
),
(
re.compile(r".*DateTime.*", re.IGNORECASE),
types.DateTime(),
GenericDataType.TEMPORAL,
),
(
re.compile(r".*Date.*", re.IGNORECASE),
types.Date(),
GenericDataType.TEMPORAL,
),
)
@classmethod
def epoch_to_dttm(cls) -> str:
return "{col}"
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: Optional[Dict[str, Any]] = None
) -> Optional[str]:
sqla_type = cls.get_sqla_column_type(target_type)
if isinstance(sqla_type, types.Date):
return f"toDate('{dttm.date().isoformat()}')"
if isinstance(sqla_type, types.DateTime):
return f"""toDateTime('{dttm.isoformat(sep=" ", timespec="seconds")}')"""
return None
class ClickHouseEngineSpec(ClickHouseBaseEngineSpec):
"""Engine spec for clickhouse_sqlalchemy connector"""
engine = "clickhouse"
engine_name = "ClickHouse"
_show_functions_column = "name"
supports_file_upload = False supports_file_upload = False
@classmethod @classmethod
@@ -73,21 +157,9 @@ class ClickHouseEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
return exception return exception
return new_exception(str(exception)) return new_exception(str(exception))
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: Optional[Dict[str, Any]] = None
) -> Optional[str]:
sqla_type = cls.get_sqla_column_type(target_type)
if isinstance(sqla_type, types.Date):
return f"toDate('{dttm.date().isoformat()}')"
if isinstance(sqla_type, types.DateTime):
return f"""toDateTime('{dttm.isoformat(sep=" ", timespec="seconds")}')"""
return None
@classmethod @classmethod
@cache_manager.cache.memoize() @cache_manager.cache.memoize()
def get_function_names(cls, database: "Database") -> List[str]: def get_function_names(cls, database: Database) -> List[str]:
""" """
Get a list of function names that are able to be called on the database. Get a list of function names that are able to be called on the database.
Used for SQL Lab autocomplete. Used for SQL Lab autocomplete.
@@ -123,3 +195,202 @@ class ClickHouseEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
# otherwise, return no function names to prevent errors # otherwise, return no function names to prevent errors
return [] return []
class ClickHouseParametersSchema(Schema):
username = fields.String(allow_none=True, description=__("Username"))
password = fields.String(allow_none=True, description=__("Password"))
host = fields.String(required=True, description=__("Hostname or IP address"))
port = fields.Integer(
allow_none=True,
description=__("Database port"),
validate=Range(min=0, max=65535),
)
database = fields.String(allow_none=True, description=__("Database name"))
encryption = fields.Boolean(
default=True, description=__("Use an encrypted connection to the database")
)
query = fields.Dict(
keys=fields.Str(), values=fields.Raw(), description=__("Additional parameters")
)
try:
from clickhouse_connect.common import set_setting
from clickhouse_connect.datatypes.format import set_default_formats
# override default formats for compatibility
set_default_formats(
"FixedString",
"string",
"IPv*",
"string",
"UInt64",
"signed",
"UUID",
"string",
"*Int256",
"string",
"*Int128",
"string",
)
set_setting(
"product_name",
f"superset/{current_app.config.get('VERSION_STRING', 'dev')}",
)
except ImportError: # ClickHouse Connect not installed, do nothing
pass
class ClickHouseConnectEngineSpec(ClickHouseEngineSpec, BasicParametersMixin):
"""Engine spec for clickhouse-connect connector"""
engine = "clickhousedb"
engine_name = "ClickHouse Connect"
default_driver = "connect"
_function_names: List[str] = []
sqlalchemy_uri_placeholder = (
"clickhousedb://user:password@host[:port][/dbname][?secure=value&=value...]"
)
parameters_schema = ClickHouseParametersSchema()
encryption_parameters = {"secure": "true"}
@classmethod
def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], Type[Exception]]:
return {}
@classmethod
def get_dbapi_mapped_exception(cls, exception: Exception) -> Exception:
new_exception = cls.get_dbapi_exception_mapping().get(type(exception))
if new_exception == SupersetDBAPIDatabaseError:
return SupersetDBAPIDatabaseError("Connection failed")
if not new_exception:
return exception
return new_exception(str(exception))
@classmethod
def get_function_names(cls, database: Database) -> List[str]:
# pylint: disable=import-outside-toplevel,import-error
from clickhouse_connect.driver.exceptions import ClickHouseError
if cls._function_names:
return cls._function_names
try:
names = database.get_df(
"SELECT name FROM system.functions UNION ALL "
+ "SELECT name FROM system.table_functions LIMIT 10000"
)["name"].tolist()
cls._function_names = names
return names
except ClickHouseError:
logger.exception("Error retrieving system.functions")
return []
@classmethod
def get_datatype(cls, type_code: str) -> str:
# keep it lowercase, as ClickHouse types aren't typical SHOUTCASE ANSI SQL
return type_code
@classmethod
def build_sqlalchemy_uri(
cls,
parameters: BasicParametersType,
encrypted_extra: Optional[Dict[str, str]] = None,
) -> str:
url_params = parameters.copy()
if url_params.get("encryption"):
query = parameters.get("query", {}).copy()
query.update(cls.encryption_parameters)
url_params["query"] = query
if not url_params.get("database"):
url_params["database"] = "__default__"
url_params.pop("encryption", None)
return str(URL(f"{cls.engine}+{cls.default_driver}", **url_params))
@classmethod
def get_parameters_from_uri(
cls, uri: str, encrypted_extra: Optional[Dict[str, Any]] = None
) -> BasicParametersType:
url = make_url_safe(uri)
query = url.query
if "secure" in query:
encryption = url.query.get("secure") == "true"
query.pop("secure")
else:
encryption = False
return BasicParametersType(
username=url.username,
password=url.password,
host=url.host,
port=url.port,
database="" if url.database == "__default__" else cast(str, url.database),
query=dict(query),
encryption=encryption,
)
@classmethod
def validate_parameters(
cls, properties: BasicPropertiesType
) -> List[SupersetError]:
# pylint: disable=import-outside-toplevel,import-error
from clickhouse_connect.driver import default_port
parameters = properties.get("parameters", {})
host = parameters.get("host", None)
if not host:
return [
SupersetError(
"Hostname is required",
SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
ErrorLevel.WARNING,
{"missing": ["host"]},
)
]
if not is_hostname_valid(host):
return [
SupersetError(
"The hostname provided can't be resolved.",
SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
ErrorLevel.ERROR,
{"invalid": ["host"]},
)
]
port = parameters.get("port")
if port is None:
port = default_port("http", parameters.get("encryption", False))
try:
port = int(port)
except (ValueError, TypeError):
port = -1
if port <= 0 or port >= 65535:
return [
SupersetError(
"Port must be a valid integer between 0 and 65535 (inclusive).",
SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
ErrorLevel.ERROR,
{"invalid": ["port"]},
)
]
if not is_port_open(host, port):
return [
SupersetError(
"The port is closed.",
SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
ErrorLevel.ERROR,
{"invalid": ["port"]},
)
]
return []
@staticmethod
def _mutate_label(label: str) -> str:
"""
Suffix with the first six characters from the md5 of the label to avoid
collisions with original column names
:param label: Expected expression label
:return: Conditionally mutated label
"""
return f"{label}_{md5_sha_from_str(label)[:6]}"
+1
View File
@@ -173,6 +173,7 @@ class MySQLEngineSpec(BaseEngineSpec, BasicParametersMixin):
{}, {},
), ),
} }
disallow_uri_query_params = {"local_infile"}
@classmethod @classmethod
def convert_dttm( def convert_dttm(
+4
View File
@@ -270,3 +270,7 @@ class SupersetCancelQueryException(SupersetException):
class QueryNotFoundException(SupersetException): class QueryNotFoundException(SupersetException):
status = 404 status = 404
class ColumnNotFoundException(SupersetException):
status = 404
+4 -4
View File
@@ -51,8 +51,7 @@ class SSHManager:
) -> SSHTunnelForwarder: ) -> SSHTunnelForwarder:
url = make_url_safe(sqlalchemy_database_uri) url = make_url_safe(sqlalchemy_database_uri)
params = { params = {
"ssh_address_or_host": ssh_tunnel.server_address, "ssh_address_or_host": (ssh_tunnel.server_address, ssh_tunnel.server_port),
"ssh_port": ssh_tunnel.server_port,
"ssh_username": ssh_tunnel.username, "ssh_username": ssh_tunnel.username,
"remote_bind_address": (url.host, url.port), # bind_port, bind_host "remote_bind_address": (url.host, url.port), # bind_port, bind_host
"local_bind_address": (self.local_bind_address,), "local_bind_address": (self.local_bind_address,),
@@ -62,9 +61,10 @@ class SSHManager:
params["ssh_password"] = ssh_tunnel.password params["ssh_password"] = ssh_tunnel.password
elif ssh_tunnel.private_key: elif ssh_tunnel.private_key:
private_key_file = StringIO(ssh_tunnel.private_key) private_key_file = StringIO(ssh_tunnel.private_key)
private_key = RSAKey.from_private_key(private_key_file) private_key = RSAKey.from_private_key(
private_key_file, ssh_tunnel.private_key_password
)
params["ssh_pkey"] = private_key params["ssh_pkey"] = private_key
params["ssh_private_key_password"] = ssh_tunnel.private_key_password
return open_tunnel(**params) return open_tunnel(**params)
+16 -2
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import logging import logging
import os import os
import sys
from typing import Any, Callable, Dict, TYPE_CHECKING from typing import Any, Callable, Dict, TYPE_CHECKING
import wtforms_json import wtforms_json
@@ -52,7 +53,7 @@ from superset.extensions import (
from superset.security import SupersetSecurityManager from superset.security import SupersetSecurityManager
from superset.superset_typing import FlaskResponse from superset.superset_typing import FlaskResponse
from superset.tags.core import register_sqla_event_listeners from superset.tags.core import register_sqla_event_listeners
from superset.utils.core import pessimistic_connection_handling from superset.utils.core import is_test, pessimistic_connection_handling
from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -439,7 +440,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
self.init_views() self.init_views()
def check_secret_key(self) -> None: def check_secret_key(self) -> None:
if self.config["SECRET_KEY"] == CHANGE_ME_SECRET_KEY: def log_default_secret_key_warning() -> None:
top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-" top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-"
bottom_banner = 80 * "-" + "\n" + 80 * "-" bottom_banner = 80 * "-" + "\n" + 80 * "-"
logger.warning(top_banner) logger.warning(top_banner)
@@ -452,6 +453,19 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
) )
logger.warning(bottom_banner) logger.warning(bottom_banner)
if self.config["SECRET_KEY"] == CHANGE_ME_SECRET_KEY:
if (
self.superset_app.debug
or self.superset_app.config["TESTING"]
or is_test()
):
logger.warning("Debug mode identified with default secret key")
log_default_secret_key_warning()
return
log_default_secret_key_warning()
logger.error("Refusing to start due to insecure SECRET_KEY")
sys.exit(1)
def init_app(self) -> None: def init_app(self) -> None:
""" """
Main entry point which will delegate to other methods in Main entry point which will delegate to other methods in
+4 -4
View File
@@ -17,7 +17,7 @@
"""Defines the templating context for SQL Lab""" """Defines the templating context for SQL Lab"""
import json import json
import re import re
from functools import partial from functools import lru_cache, partial
from typing import ( from typing import (
Any, Any,
Callable, Callable,
@@ -38,6 +38,7 @@ from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.types import String from sqlalchemy.types import String
from typing_extensions import TypedDict from typing_extensions import TypedDict
from superset.constants import LRU_CACHE_MAX_SIZE
from superset.datasets.commands.exceptions import DatasetNotFoundError from superset.datasets.commands.exceptions import DatasetNotFoundError
from superset.exceptions import SupersetTemplateException from superset.exceptions import SupersetTemplateException
from superset.extensions import feature_flag_manager from superset.extensions import feature_flag_manager
@@ -46,7 +47,6 @@ from superset.utils.core import (
get_user_id, get_user_id,
merge_extra_filters, merge_extra_filters,
) )
from superset.utils.memoized import memoized
if TYPE_CHECKING: if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlaTable from superset.connectors.sqla.models import SqlaTable
@@ -70,7 +70,7 @@ ALLOWED_TYPES = (
COLLECTION_TYPES = ("list", "dict", "tuple", "set") COLLECTION_TYPES = ("list", "dict", "tuple", "set")
@memoized @lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
def context_addons() -> Dict[str, Any]: def context_addons() -> Dict[str, Any]:
return current_app.config.get("JINJA_CONTEXT_ADDONS", {}) return current_app.config.get("JINJA_CONTEXT_ADDONS", {})
@@ -602,7 +602,7 @@ DEFAULT_PROCESSORS = {
} }
@memoized @lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
def get_template_processors() -> Dict[str, Any]: def get_template_processors() -> Dict[str, Any]:
processors = current_app.config.get("CUSTOM_TEMPLATE_PROCESSORS", {}) processors = current_app.config.get("CUSTOM_TEMPLATE_PROCESSORS", {})
for engine, processor in DEFAULT_PROCESSORS.items(): for engine, processor in DEFAULT_PROCESSORS.items():
@@ -34,7 +34,6 @@ from sqlalchemy.ext.declarative import declarative_base
from superset import db, db_engine_specs from superset import db, db_engine_specs
from superset.databases.utils import make_url_safe from superset.databases.utils import make_url_safe
from superset.utils.memoized import memoized
Base = declarative_base() Base = declarative_base()
@@ -70,7 +69,6 @@ class Slice(Base):
datasource_id = Column(Integer) datasource_id = Column(Integer)
@memoized
def duration_by_name(database: Database): def duration_by_name(database: Database):
return {grain.name: grain.duration for grain in database.grains()} return {grain.name: grain.duration for grain in database.grains()}
+5 -4
View File
@@ -24,6 +24,7 @@ from ast import literal_eval
from contextlib import closing, contextmanager, nullcontext from contextlib import closing, contextmanager, nullcontext
from copy import deepcopy from copy import deepcopy
from datetime import datetime from datetime import datetime
from functools import lru_cache
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TYPE_CHECKING from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TYPE_CHECKING
import numpy import numpy
@@ -54,7 +55,7 @@ from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.sql import expression, Select from sqlalchemy.sql import expression, Select
from superset import app, db_engine_specs from superset import app, db_engine_specs
from superset.constants import PASSWORD_MASK from superset.constants import LRU_CACHE_MAX_SIZE, PASSWORD_MASK
from superset.databases.utils import make_url_safe from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import MetricType, TimeGrain from superset.db_engine_specs.base import MetricType, TimeGrain
from superset.extensions import ( from superset.extensions import (
@@ -67,7 +68,6 @@ from superset.models.helpers import AuditMixinNullable, ImportExportMixin
from superset.result_set import SupersetResultSet from superset.result_set import SupersetResultSet
from superset.utils import cache as cache_util, core as utils from superset.utils import cache as cache_util, core as utils
from superset.utils.core import get_username from superset.utils.core import get_username
from superset.utils.memoized import memoized
config = app.config config = app.config
custom_password_store = config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"] custom_password_store = config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"]
@@ -424,6 +424,8 @@ class Database(
sqlalchemy_url = make_url_safe( sqlalchemy_url = make_url_safe(
sqlalchemy_uri if sqlalchemy_uri else self.sqlalchemy_uri_decrypted sqlalchemy_uri if sqlalchemy_uri else self.sqlalchemy_uri_decrypted
) )
self.db_engine_spec.validate_database_uri(sqlalchemy_url)
sqlalchemy_url = self.db_engine_spec.adjust_database_uri(sqlalchemy_url, schema) sqlalchemy_url = self.db_engine_spec.adjust_database_uri(sqlalchemy_url, schema)
effective_username = self.get_effective_user(sqlalchemy_url) effective_username = self.get_effective_user(sqlalchemy_url)
# If using MySQL or Presto for example, will set url.username # If using MySQL or Presto for example, will set url.username
@@ -723,7 +725,7 @@ class Database(
return self.get_db_engine_spec(url) return self.get_db_engine_spec(url)
@classmethod @classmethod
@memoized @lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
def get_db_engine_spec(cls, url: URL) -> Type[db_engine_specs.BaseEngineSpec]: def get_db_engine_spec(cls, url: URL) -> Type[db_engine_specs.BaseEngineSpec]:
backend = url.get_backend_name() backend = url.get_backend_name()
try: try:
@@ -897,7 +899,6 @@ class Database(
def has_view_by_name(self, view_name: str, schema: Optional[str] = None) -> bool: def has_view_by_name(self, view_name: str, schema: Optional[str] = None) -> bool:
return self.has_view(view_name=view_name, schema=schema) return self.has_view(view_name=view_name, schema=schema)
@memoized
def get_dialect(self) -> Dialect: def get_dialect(self) -> Dialect:
sqla_url = make_url_safe(self.sqlalchemy_uri_decrypted) sqla_url = make_url_safe(self.sqlalchemy_uri_decrypted)
return sqla_url.get_dialect()() return sqla_url.get_dialect()()
@@ -22,7 +22,6 @@ from sqlalchemy import Column, Integer, String
from superset import app, db, security_manager from superset import app, db, security_manager
from superset.models.helpers import AuditMixinNullable from superset.models.helpers import AuditMixinNullable
from superset.utils.memoized import memoized
if TYPE_CHECKING: if TYPE_CHECKING:
from superset.connectors.base.models import BaseDatasource from superset.connectors.base.models import BaseDatasource
@@ -57,7 +56,6 @@ class DatasourceAccessRequest(Model, AuditMixinNullable):
return self.get_datasource return self.get_datasource
@datasource.getter # type: ignore @datasource.getter # type: ignore
@memoized
def get_datasource(self) -> "BaseDatasource": def get_datasource(self) -> "BaseDatasource":
ds = db.session.query(self.cls_model).filter_by(id=self.datasource_id).first() ds = db.session.query(self.cls_model).filter_by(id=self.datasource_id).first()
return ds return ds
+6 -1
View File
@@ -80,6 +80,7 @@ from superset.jinja_context import BaseTemplateProcessor
from superset.sql_parse import has_table_query, insert_rls, ParsedQuery, sanitize_clause from superset.sql_parse import has_table_query, insert_rls, ParsedQuery, sanitize_clause
from superset.superset_typing import ( from superset.superset_typing import (
AdhocMetric, AdhocMetric,
Column as ColumnTyping,
FilterValue, FilterValue,
FilterValues, FilterValues,
Metric, Metric,
@@ -545,6 +546,8 @@ class QueryResult: # pylint: disable=too-few-public-methods
query: str, query: str,
duration: timedelta, duration: timedelta,
applied_template_filters: Optional[List[str]] = None, applied_template_filters: Optional[List[str]] = None,
applied_filter_columns: Optional[List[ColumnTyping]] = None,
rejected_filter_columns: Optional[List[ColumnTyping]] = None,
status: str = QueryStatus.SUCCESS, status: str = QueryStatus.SUCCESS,
error_message: Optional[str] = None, error_message: Optional[str] = None,
errors: Optional[List[Dict[str, Any]]] = None, errors: Optional[List[Dict[str, Any]]] = None,
@@ -555,6 +558,8 @@ class QueryResult: # pylint: disable=too-few-public-methods
self.query = query self.query = query
self.duration = duration self.duration = duration
self.applied_template_filters = applied_template_filters or [] self.applied_template_filters = applied_template_filters or []
self.applied_filter_columns = applied_filter_columns or []
self.rejected_filter_columns = rejected_filter_columns or []
self.status = status self.status = status
self.error_message = error_message self.error_message = error_message
self.errors = errors or [] self.errors = errors or []
@@ -1646,7 +1651,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
elif utils.is_adhoc_column(flt_col): elif utils.is_adhoc_column(flt_col):
sqla_col = self.adhoc_column_to_sqla(flt_col) # type: ignore sqla_col = self.adhoc_column_to_sqla(flt_col) # type: ignore
else: else:
col_obj = columns_by_name.get(flt_col) col_obj = columns_by_name.get(cast(str, flt_col))
filter_grain = flt.get("grain") filter_grain = flt.get("grain")
if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"): if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"):
+6 -5
View File
@@ -46,7 +46,6 @@ from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.tasks.utils import get_current_user from superset.tasks.utils import get_current_user
from superset.thumbnails.digest import get_chart_digest from superset.thumbnails.digest import get_chart_digest
from superset.utils import core as utils from superset.utils import core as utils
from superset.utils.memoized import memoized
from superset.viz import BaseViz, viz_types from superset.viz import BaseViz, viz_types
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -151,9 +150,12 @@ class Slice( # pylint: disable=too-many-public-methods
# pylint: disable=using-constant-test # pylint: disable=using-constant-test
@datasource.getter # type: ignore @datasource.getter # type: ignore
@memoized
def get_datasource(self) -> Optional["BaseDatasource"]: def get_datasource(self) -> Optional["BaseDatasource"]:
return db.session.query(self.cls_model).filter_by(id=self.datasource_id).first() return (
db.session.query(self.cls_model)
.filter_by(id=self.datasource_id)
.one_or_none()
)
@renders("datasource_name") @renders("datasource_name")
def datasource_link(self) -> Optional[Markup]: def datasource_link(self) -> Optional[Markup]:
@@ -189,8 +191,7 @@ class Slice( # pylint: disable=too-many-public-methods
# pylint: enable=using-constant-test # pylint: enable=using-constant-test
@property # type: ignore @property
@memoized
def viz(self) -> Optional[BaseViz]: def viz(self) -> Optional[BaseViz]:
form_data = json.loads(self.params) form_data = json.loads(self.params)
viz_class = viz_types.get(self.viz_type) viz_class = viz_types.get(self.viz_type)
+1 -1
View File
@@ -137,6 +137,7 @@ class QueryRestApi(BaseSupersetModelRestApi):
base_related_field_filters = { base_related_field_filters = {
"created_by": [["id", BaseFilterRelatedUsers, lambda: []]], "created_by": [["id", BaseFilterRelatedUsers, lambda: []]],
"user": [["id", BaseFilterRelatedUsers, lambda: []]], "user": [["id", BaseFilterRelatedUsers, lambda: []]],
"database": [["id", DatabaseFilter, lambda: []]],
} }
related_field_filters = { related_field_filters = {
"created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners),
@@ -145,7 +146,6 @@ class QueryRestApi(BaseSupersetModelRestApi):
search_columns = ["changed_on", "database", "sql", "status", "user", "start_time"] search_columns = ["changed_on", "database", "sql", "status", "user", "start_time"]
base_related_field_filters = {"database": [["id", DatabaseFilter, lambda: []]]}
allowed_rel_fields = {"database", "user"} allowed_rel_fields = {"database", "user"}
allowed_distinct_fields = {"status"} allowed_distinct_fields = {"status"}
+56 -16
View File
@@ -84,6 +84,7 @@ from superset.utils.core import (
get_user_id, get_user_id,
RowLevelSecurityFilterType, RowLevelSecurityFilterType,
) )
from superset.utils.filters import get_dataset_access_filters
from superset.utils.urls import get_url_host from superset.utils.urls import get_url_host
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -98,6 +99,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DATABASE_PERM_REGEX = re.compile(r"^\[.+\]\.\(id\:(?P<id>\d+)\)$")
class DatabaseAndSchema(NamedTuple): class DatabaseAndSchema(NamedTuple):
database: str database: str
@@ -154,40 +157,53 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
READ_ONLY_MODEL_VIEWS = {"Database", "DruidClusterModelView", "DynamicPlugin"} READ_ONLY_MODEL_VIEWS = {"Database", "DruidClusterModelView", "DynamicPlugin"}
USER_MODEL_VIEWS = { USER_MODEL_VIEWS = {
"RegisterUserModelView",
"UserDBModelView", "UserDBModelView",
"UserLDAPModelView", "UserLDAPModelView",
"UserInfoEditView",
"UserOAuthModelView", "UserOAuthModelView",
"UserOIDModelView", "UserOIDModelView",
"UserRemoteUserModelView", "UserRemoteUserModelView",
} }
GAMMA_READ_ONLY_MODEL_VIEWS = { GAMMA_READ_ONLY_MODEL_VIEWS = {
"Annotation",
"CssTemplate",
"Dataset", "Dataset",
"Datasource", "Datasource",
"CssTemplate",
} | READ_ONLY_MODEL_VIEWS } | READ_ONLY_MODEL_VIEWS
ADMIN_ONLY_VIEW_MENUS = { ADMIN_ONLY_VIEW_MENUS = {
"Access Requests",
"AccessRequestsModelView", "AccessRequestsModelView",
"SQL Lab", "Action Log",
"Log",
"List Users",
"List Roles",
"Refresh Druid Metadata", "Refresh Druid Metadata",
"ResetPasswordView", "ResetPasswordView",
"RoleModelView", "RoleModelView",
"Log",
"Security",
"Row Level Security", "Row Level Security",
"Row Level Security Filters", "Row Level Security Filters",
"RowLevelSecurityFiltersModelView", "RowLevelSecurityFiltersModelView",
"Security",
"SQL Lab",
} | USER_MODEL_VIEWS } | USER_MODEL_VIEWS
ALPHA_ONLY_VIEW_MENUS = { ALPHA_ONLY_VIEW_MENUS = {
"Manage", "Manage",
"CSS Templates", "CSS Templates",
"Annotation Layers",
"Queries", "Queries",
"Import dashboards", "Import dashboards",
"Upload a CSV", "Upload a CSV",
"ReportSchedule", "ReportSchedule",
"Alerts & Report", "Alerts & Report",
"TableSchemaView",
"CsvToDatabaseView",
"ColumnarToDatabaseView",
"ExcelToDatabaseView",
"ImportExportRestApi",
} }
ADMIN_ONLY_PERMISSIONS = { ADMIN_ONLY_PERMISSIONS = {
@@ -199,6 +215,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"all_query_access", "all_query_access",
"can_grant_guest_token", "can_grant_guest_token",
"can_set_embedded", "can_set_embedded",
"can_warm_up_cache",
} }
READ_ONLY_PERMISSION = { READ_ONLY_PERMISSION = {
@@ -222,16 +239,33 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"datasource_access", "datasource_access",
} }
ACCESSIBLE_PERMS = {"can_userinfo", "resetmypassword"} ACCESSIBLE_PERMS = {"can_userinfo", "resetmypassword", "can_recent_activity"}
SQLLAB_ONLY_PERMISSIONS = { SQLLAB_ONLY_PERMISSIONS = {
("can_my_queries", "SqlLab"), ("can_my_queries", "SqlLab"),
("can_read", "SavedQuery"), ("can_read", "SavedQuery"),
("can_sql_json", "Superset"), ("can_write", "SavedQuery"),
("can_export", "SavedQuery"),
("can_read", "Query"),
("can_export_csv", "Query"),
("can_get_results", "SQLLab"),
("can_execute_sql_query", "SQLLab"),
("can_export_csv", "SQLLab"),
("can_sql_json", "Superset"), # Deprecated permission remove on 3.0.0
("can_sqllab_history", "Superset"), ("can_sqllab_history", "Superset"),
("can_sqllab_viz", "Superset"), ("can_sqllab_viz", "Superset"),
("can_sqllab_table_viz", "Superset"), ("can_sqllab_table_viz", "Superset"), # Deprecated permission remove on 3.0.0
("can_sqllab", "Superset"), ("can_sqllab", "Superset"),
("can_stop_query", "Superset"), # Deprecated permission remove on 3.0.0
("can_test_conn", "Superset"), # Deprecated permission remove on 3.0.0
("can_search_queries", "Superset"), # Deprecated permission remove on 3.0.0
("can_activate", "TabStateView"),
("can_get", "TabStateView"),
("can_delete_query", "TabStateView"),
("can_post", "TabStateView"),
("can_delete", "TabStateView"),
("can_put", "TabStateView"),
("can_migrate_query", "TabStateView"),
("menu_access", "SQL Lab"), ("menu_access", "SQL Lab"),
("menu_access", "SQL Editor"), ("menu_access", "SQL Editor"),
("menu_access", "Saved Queries"), ("menu_access", "Saved Queries"),
@@ -239,7 +273,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
} }
SQLLAB_EXTRA_PERMISSION_VIEWS = { SQLLAB_EXTRA_PERMISSION_VIEWS = {
("can_csv", "Superset"), ("can_csv", "Superset"), # Deprecated permission remove on 3.0.0
("can_read", "Superset"), ("can_read", "Superset"),
("can_read", "Database"), ("can_read", "Database"),
} }
@@ -494,8 +528,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
:returns: The list of datasources :returns: The list of datasources
""" """
user_perms = self.user_view_menu_names("datasource_access")
schema_perms = self.user_view_menu_names("schema_access")
user_datasources = set() user_datasources = set()
# pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
@@ -503,12 +535,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
user_datasources.update( user_datasources.update(
self.get_session.query(SqlaTable) self.get_session.query(SqlaTable)
.filter( .filter(get_dataset_access_filters(SqlaTable))
or_(
SqlaTable.perm.in_(user_perms),
SqlaTable.schema_perm.in_(schema_perms),
)
)
.all() .all()
) )
@@ -573,6 +600,19 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return {s.name for s in view_menu_names} return {s.name for s in view_menu_names}
return set() return set()
def get_accessible_databases(self) -> List[int]:
"""
Return the list of databases accessible by the user.
:return: The list of accessible Databases
"""
perms = self.user_view_menu_names("database_access")
return [
int(match.group("id"))
for perm in perms
if (match := DATABASE_PERM_REGEX.match(perm))
]
def get_schemas_accessible_by_user( def get_schemas_accessible_by_user(
self, database: "Database", schemas: List[str], hierarchical: bool = True self, database: "Database", schemas: List[str], hierarchical: bool = True
) -> List[str]: ) -> List[str]:
+1 -1
View File
@@ -68,7 +68,7 @@ class SqlLabRestApi(BaseSupersetApi):
resource_name = "sqllab" resource_name = "sqllab"
allow_browser_login = True allow_browser_login = True
class_permission_name = "Query" class_permission_name = "SQLLab"
execute_model_schema = ExecutePayloadSchema() execute_model_schema = ExecutePayloadSchema()
+2 -2
View File
@@ -221,7 +221,7 @@ class AdhocFilterClause(TypedDict, total=False):
class QueryObjectFilterClause(TypedDict, total=False): class QueryObjectFilterClause(TypedDict, total=False):
col: str col: Column
op: str # pylint: disable=invalid-name op: str # pylint: disable=invalid-name
val: Optional[FilterValues] val: Optional[FilterValues]
grain: Optional[str] grain: Optional[str]
@@ -1089,7 +1089,7 @@ def simple_filter_to_adhoc(
"expressionType": "SIMPLE", "expressionType": "SIMPLE",
"comparator": filter_clause.get("val"), "comparator": filter_clause.get("val"),
"operator": filter_clause["op"], "operator": filter_clause["op"],
"subject": filter_clause["col"], "subject": cast(str, filter_clause["col"]),
} }
if filter_clause.get("isExtra"): if filter_clause.get("isExtra"):
result["isExtra"] = True result["isExtra"] = True
+3 -3
View File
@@ -18,6 +18,7 @@ import calendar
import logging import logging
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import lru_cache
from time import struct_time from time import struct_time
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
@@ -45,8 +46,7 @@ from superset.charts.commands.exceptions import (
TimeRangeAmbiguousError, TimeRangeAmbiguousError,
TimeRangeParseFailError, TimeRangeParseFailError,
) )
from superset.constants import NO_TIME_RANGE from superset.constants import LRU_CACHE_MAX_SIZE, NO_TIME_RANGE
from superset.utils.memoized import memoized
ParserElement.enablePackrat() ParserElement.enablePackrat()
@@ -394,7 +394,7 @@ class EvalHolidayFunc: # pylint: disable=too-few-public-methods
) )
@memoized @lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
def datetime_parser() -> ParseResults: # pylint: disable=too-many-locals def datetime_parser() -> ParseResults: # pylint: disable=too-many-locals
( # pylint: disable=invalid-name ( # pylint: disable=invalid-name
DATETIME, DATETIME,
+41
View File
@@ -0,0 +1,41 @@
# 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 typing import Any, Type
from flask_appbuilder import Model
from sqlalchemy import or_
from sqlalchemy.sql.elements import BooleanClauseList
def get_dataset_access_filters(
base_model: Type[Model],
*args: Any,
) -> BooleanClauseList:
# pylint: disable=import-outside-toplevel
from superset import security_manager
from superset.connectors.sqla.models import Database
database_ids = security_manager.get_accessible_databases()
perms = security_manager.user_view_menu_names("datasource_access")
schema_perms = security_manager.user_view_menu_names("schema_access")
return or_(
Database.id.in_(database_ids),
base_model.perm.in_(perms),
base_model.schema_perm.in_(schema_perms),
*args,
)
-81
View File
@@ -1,81 +0,0 @@
# 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 functools
from typing import Any, Callable, Dict, Optional, Tuple, Type
class _memoized:
"""Decorator that caches a function's return value each time it is called
If called later with the same arguments, the cached value is returned, and
not re-evaluated.
Define ``watch`` as a tuple of attribute names if this Decorator
should account for instance variable changes.
"""
def __init__(
self, func: Callable[..., Any], watch: Optional[Tuple[str, ...]] = None
) -> None:
self.func = func
self.cache: Dict[Any, Any] = {}
self.is_method = False
self.watch = watch or ()
def __call__(self, *args: Any, **kwargs: Any) -> Any:
key = [args, frozenset(kwargs.items())]
if self.is_method:
key.append(tuple(getattr(args[0], v, None) for v in self.watch))
key = tuple(key) # type: ignore
try:
if key in self.cache:
return self.cache[key]
except TypeError as ex:
# Uncachable -- for instance, passing a list as an argument.
raise TypeError("Function cannot be memoized") from ex
value = self.func(*args, **kwargs)
try:
self.cache[key] = value
except TypeError as ex:
raise TypeError("Function cannot be memoized") from ex
return value
def __repr__(self) -> str:
"""Return the function's docstring."""
return self.func.__doc__ or ""
def __get__(
self, obj: Any, objtype: Type[Any]
) -> functools.partial: # type: ignore
if not self.is_method:
self.is_method = True
# Support instance methods.
func = functools.partial(self.__call__, obj)
func.__func__ = self.func # type: ignore
return func
def memoized(
func: Optional[Callable[..., Any]] = None, watch: Optional[Tuple[str, ...]] = None
) -> Callable[..., Any]:
if func:
return _memoized(func)
def wrapper(f: Callable[..., Any]) -> Callable[..., Any]:
return _memoized(f, watch)
return wrapper
+16 -18
View File
@@ -46,7 +46,7 @@ from flask_jwt_extended.exceptions import NoAuthorizationError
from flask_wtf.csrf import CSRFError from flask_wtf.csrf import CSRFError
from flask_wtf.form import FlaskForm from flask_wtf.form import FlaskForm
from pkg_resources import resource_filename from pkg_resources import resource_filename
from sqlalchemy import exc, or_ from sqlalchemy import exc
from sqlalchemy.orm import Query from sqlalchemy.orm import Query
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from wtforms import Form from wtforms import Form
@@ -78,7 +78,7 @@ from superset.reports.models import ReportRecipientType
from superset.superset_typing import FlaskResponse from superset.superset_typing import FlaskResponse
from superset.translations.utils import get_language_pack from superset.translations.utils import get_language_pack
from superset.utils import core as utils from superset.utils import core as utils
from superset.utils.core import get_user_id from superset.utils.filters import get_dataset_access_filters
from .utils import bootstrap_user_data from .utils import bootstrap_user_data
@@ -188,6 +188,7 @@ def generate_download_headers(
def deprecated( def deprecated(
eol_version: str = "3.0.0", eol_version: str = "3.0.0",
new_target: Optional[str] = None,
) -> Callable[[Callable[..., FlaskResponse]], Callable[..., FlaskResponse]]: ) -> Callable[[Callable[..., FlaskResponse]], Callable[..., FlaskResponse]]:
""" """
A decorator to set an API endpoint from SupersetView has deprecated. A decorator to set an API endpoint from SupersetView has deprecated.
@@ -196,13 +197,19 @@ def deprecated(
def _deprecated(f: Callable[..., FlaskResponse]) -> Callable[..., FlaskResponse]: def _deprecated(f: Callable[..., FlaskResponse]) -> Callable[..., FlaskResponse]:
def wraps(self: "BaseSupersetView", *args: Any, **kwargs: Any) -> FlaskResponse: def wraps(self: "BaseSupersetView", *args: Any, **kwargs: Any) -> FlaskResponse:
logger.warning( messsage = (
"%s.%s " "%s.%s "
"This API endpoint is deprecated and will be removed in version %s", "This API endpoint is deprecated and will be removed in version %s"
)
logger_args = [
self.__class__.__name__, self.__class__.__name__,
f.__name__, f.__name__,
eol_version, eol_version,
) ]
if new_target:
messsage += " . Use the following API endpoint instead: %s"
logger_args.append(new_target)
logger.warning(messsage, *logger_args)
return f(self, *args, **kwargs) return f(self, *args, **kwargs)
return functools.update_wrapper(wraps, f) return functools.update_wrapper(wraps, f)
@@ -670,20 +677,11 @@ class DatasourceFilter(BaseFilter): # pylint: disable=too-few-public-methods
def apply(self, query: Query, value: Any) -> Query: def apply(self, query: Query, value: Any) -> Query:
if security_manager.can_access_all_datasources(): if security_manager.can_access_all_datasources():
return query return query
datasource_perms = security_manager.user_view_menu_names("datasource_access") query = query.join(
schema_perms = security_manager.user_view_menu_names("schema_access") models.Database,
owner_ids_query = ( models.Database.id == self.model.database_id,
db.session.query(models.SqlaTable.id)
.join(models.SqlaTable.owners)
.filter(security_manager.user_model.id == get_user_id())
)
return query.filter(
or_(
self.model.perm.in_(datasource_perms),
self.model.schema_perm.in_(schema_perms),
models.SqlaTable.id.in_(owner_ids_query),
)
) )
return query.filter(get_dataset_access_filters(self.model))
class CsvResponse(Response): class CsvResponse(Response):
+3 -6
View File
@@ -16,10 +16,10 @@
# under the License. # under the License.
from typing import Any from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm.query import Query from sqlalchemy.orm.query import Query
from superset import security_manager from superset import security_manager
from superset.utils.filters import get_dataset_access_filters
from superset.views.base import BaseFilter from superset.views.base import BaseFilter
@@ -27,8 +27,5 @@ class SliceFilter(BaseFilter): # pylint: disable=too-few-public-methods
def apply(self, query: Query, value: Any) -> Query: def apply(self, query: Query, value: Any) -> Query:
if security_manager.can_access_all_datasources(): if security_manager.can_access_all_datasources():
return query return query
perms = security_manager.user_view_menu_names("datasource_access")
schema_perms = security_manager.user_view_menu_names("schema_access") return query.filter(get_dataset_access_filters(self.model))
return query.filter(
or_(self.model.perm.in_(perms), self.model.schema_perm.in_(schema_perms))
)
+27 -22
View File
@@ -211,7 +211,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/datasources/") @expose("/datasources/")
@deprecated() @deprecated(new_target="api/v1/dataset/")
def datasources(self) -> FlaskResponse: def datasources(self) -> FlaskResponse:
return self.json_response( return self.json_response(
sorted( sorted(
@@ -505,7 +505,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@expose("/slice_json/<int:slice_id>") @expose("/slice_json/<int:slice_id>")
@etag_cache() @etag_cache()
@check_resource_permissions(check_slice_perms) @check_resource_permissions(check_slice_perms)
@deprecated() @deprecated(new_target="/api/v1/chart/<int:id>/data/")
def slice_json(self, slice_id: int) -> FlaskResponse: def slice_json(self, slice_id: int) -> FlaskResponse:
form_data, slc = get_form_data(slice_id, use_slice_data=True) form_data, slc = get_form_data(slice_id, use_slice_data=True)
if not slc: if not slc:
@@ -529,7 +529,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/annotation_json/<int:layer_id>") @expose("/annotation_json/<int:layer_id>")
@deprecated() @deprecated(new_target="/api/v1/chart/<int:id>/data/")
def annotation_json( # pylint: disable=no-self-use def annotation_json( # pylint: disable=no-self-use
self, layer_id: int self, layer_id: int
) -> FlaskResponse: ) -> FlaskResponse:
@@ -999,7 +999,10 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/filter/<datasource_type>/<int:datasource_id>/<column>/") @expose("/filter/<datasource_type>/<int:datasource_id>/<column>/")
@deprecated() @deprecated(
new_target="/api/v1/datasource/<datasource_type>/"
"<datasource_id>/column/<column_name>/values/"
)
def filter( # pylint: disable=no-self-use def filter( # pylint: disable=no-self-use
self, datasource_type: str, datasource_id: int, column: str self, datasource_type: str, datasource_id: int, column: str
) -> FlaskResponse: ) -> FlaskResponse:
@@ -1143,7 +1146,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@event_logger.log_this @event_logger.log_this
@expose("/tables/<int:db_id>/<schema>/") @expose("/tables/<int:db_id>/<schema>/")
@expose("/tables/<int:db_id>/<schema>/<force_refresh>/") @expose("/tables/<int:db_id>/<schema>/<force_refresh>/")
@deprecated() @deprecated(new_target="api/v1/database/<int:pk>/tables/")
def tables( # pylint: disable=no-self-use def tables( # pylint: disable=no-self-use
self, self,
db_id: int, db_id: int,
@@ -1352,7 +1355,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/testconn", methods=["POST", "GET"]) @expose("/testconn", methods=["POST", "GET"])
@deprecated() @deprecated(new_target="/api/v1/database/test_connection/")
def testconn(self) -> FlaskResponse: # pylint: disable=no-self-use def testconn(self) -> FlaskResponse: # pylint: disable=no-self-use
"""Tests a sqla connection""" """Tests a sqla connection"""
db_name = request.json.get("name") db_name = request.json.get("name")
@@ -1441,7 +1444,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/recent_activity/<int:user_id>/", methods=["GET"]) @expose("/recent_activity/<int:user_id>/", methods=["GET"])
@deprecated() @deprecated(new_target="/api/v1/log/recent_activity/<user_id>/")
def recent_activity(self, user_id: int) -> FlaskResponse: def recent_activity(self, user_id: int) -> FlaskResponse:
"""Recent activity (actions) for a given user""" """Recent activity (actions) for a given user"""
error_obj = self.get_user_activity_access_error(user_id) error_obj = self.get_user_activity_access_error(user_id)
@@ -1462,7 +1465,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/available_domains/", methods=["GET"]) @expose("/available_domains/", methods=["GET"])
@deprecated() @deprecated(new_target="/api/v1/available_domains/")
def available_domains(self) -> FlaskResponse: # pylint: disable=no-self-use def available_domains(self) -> FlaskResponse: # pylint: disable=no-self-use
""" """
Returns the list of available Superset Webserver domains (if any) Returns the list of available Superset Webserver domains (if any)
@@ -1477,7 +1480,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/fave_dashboards_by_username/<username>/", methods=["GET"]) @expose("/fave_dashboards_by_username/<username>/", methods=["GET"])
@deprecated() @deprecated(new_target="api/v1/dashboard/favorite_status/")
def fave_dashboards_by_username(self, username: str) -> FlaskResponse: def fave_dashboards_by_username(self, username: str) -> FlaskResponse:
"""This lets us use a user's username to pull favourite dashboards""" """This lets us use a user's username to pull favourite dashboards"""
user = security_manager.find_user(username=username) user = security_manager.find_user(username=username)
@@ -1487,7 +1490,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/fave_dashboards/<int:user_id>/", methods=["GET"]) @expose("/fave_dashboards/<int:user_id>/", methods=["GET"])
@deprecated() @deprecated(new_target="api/v1/dashboard/favorite_status/")
def fave_dashboards(self, user_id: int) -> FlaskResponse: def fave_dashboards(self, user_id: int) -> FlaskResponse:
error_obj = self.get_user_activity_access_error(user_id) error_obj = self.get_user_activity_access_error(user_id)
if error_obj: if error_obj:
@@ -1524,7 +1527,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/created_dashboards/<int:user_id>/", methods=["GET"]) @expose("/created_dashboards/<int:user_id>/", methods=["GET"])
@deprecated() @deprecated(new_target="api/v1/dashboard/")
def created_dashboards(self, user_id: int) -> FlaskResponse: def created_dashboards(self, user_id: int) -> FlaskResponse:
error_obj = self.get_user_activity_access_error(user_id) error_obj = self.get_user_activity_access_error(user_id)
if error_obj: if error_obj:
@@ -1609,7 +1612,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@event_logger.log_this @event_logger.log_this
@expose("/created_slices", methods=["GET"]) @expose("/created_slices", methods=["GET"])
@expose("/created_slices/<int:user_id>/", methods=["GET"]) @expose("/created_slices/<int:user_id>/", methods=["GET"])
@deprecated() @deprecated(new_target="api/v1/chart/")
def created_slices(self, user_id: Optional[int] = None) -> FlaskResponse: def created_slices(self, user_id: Optional[int] = None) -> FlaskResponse:
"""List of slices created by this user""" """List of slices created by this user"""
if not user_id: if not user_id:
@@ -1926,7 +1929,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access @has_access
@expose("/get_or_create_table/", methods=["POST"]) @expose("/get_or_create_table/", methods=["POST"])
@event_logger.log_this @event_logger.log_this
@deprecated() @deprecated(new_target="api/v1/dataset/get_or_create/")
def sqllab_table_viz(self) -> FlaskResponse: # pylint: disable=no-self-use def sqllab_table_viz(self) -> FlaskResponse: # pylint: disable=no-self-use
"""Gets or creates a table object with attributes passed to the API. """Gets or creates a table object with attributes passed to the API.
@@ -2041,7 +2044,9 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access @has_access
@expose("/extra_table_metadata/<int:database_id>/<table_name>/<schema>/") @expose("/extra_table_metadata/<int:database_id>/<table_name>/<schema>/")
@event_logger.log_this @event_logger.log_this
@deprecated() @deprecated(
new_target="api/v1/database/<int:pk>/table_extra/<table_name>/<schema_name>/"
)
def extra_table_metadata( # pylint: disable=no-self-use def extra_table_metadata( # pylint: disable=no-self-use
self, database_id: int, table_name: str, schema: str self, database_id: int, table_name: str, schema: str
) -> FlaskResponse: ) -> FlaskResponse:
@@ -2099,7 +2104,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@expose("/results/<key>/") @expose("/results/<key>/")
@event_logger.log_this @event_logger.log_this
@deprecated() @deprecated(new_target="/api/v1/sqllab/results/")
def results(self, key: str) -> FlaskResponse: def results(self, key: str) -> FlaskResponse:
return self.results_exec(key) return self.results_exec(key)
@@ -2221,7 +2226,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
on_giveup=lambda details: db.session.rollback(), on_giveup=lambda details: db.session.rollback(),
max_tries=5, max_tries=5,
) )
@deprecated() @deprecated(new_target="/api/v1/query/stop")
def stop_query(self) -> FlaskResponse: def stop_query(self) -> FlaskResponse:
client_id = request.form.get("client_id") client_id = request.form.get("client_id")
query = db.session.query(Query).filter_by(client_id=client_id).one() query = db.session.query(Query).filter_by(client_id=client_id).one()
@@ -2250,7 +2255,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api @has_access_api
@event_logger.log_this @event_logger.log_this
@expose("/validate_sql_json/", methods=["POST", "GET"]) @expose("/validate_sql_json/", methods=["POST", "GET"])
@deprecated() @deprecated(new_target="/api/v1/database/<pk>/validate_sql/")
def validate_sql_json( def validate_sql_json(
# pylint: disable=too-many-locals,no-self-use # pylint: disable=too-many-locals,no-self-use
self, self,
@@ -2323,7 +2328,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@handle_api_exception @handle_api_exception
@event_logger.log_this @event_logger.log_this
@expose("/sql_json/", methods=["POST"]) @expose("/sql_json/", methods=["POST"])
@deprecated() @deprecated(new_target="/api/v1/sqllab/execute/")
def sql_json(self) -> FlaskResponse: def sql_json(self) -> FlaskResponse:
errors = SqlJsonPayloadSchema().validate(request.json) errors = SqlJsonPayloadSchema().validate(request.json)
if errors: if errors:
@@ -2401,7 +2406,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access @has_access
@event_logger.log_this @event_logger.log_this
@expose("/csv/<client_id>") @expose("/csv/<client_id>")
@deprecated() @deprecated(new_target="/api/v1/sqllab/export/")
def csv(self, client_id: str) -> FlaskResponse: # pylint: disable=no-self-use def csv(self, client_id: str) -> FlaskResponse: # pylint: disable=no-self-use
"""Download the query results as csv.""" """Download the query results as csv."""
logger.info("Exporting CSV file [%s]", client_id) logger.info("Exporting CSV file [%s]", client_id)
@@ -2475,7 +2480,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access @has_access
@event_logger.log_this @event_logger.log_this
@expose("/fetch_datasource_metadata") @expose("/fetch_datasource_metadata")
@deprecated() @deprecated(new_target="api/v1/database/<int:pk>/table/<table_name>/<schema_name>/")
def fetch_datasource_metadata(self) -> FlaskResponse: # pylint: disable=no-self-use def fetch_datasource_metadata(self) -> FlaskResponse: # pylint: disable=no-self-use
""" """
Fetch the datasource metadata. Fetch the datasource metadata.
@@ -2498,7 +2503,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@event_logger.log_this @event_logger.log_this
@expose("/queries/<float:last_updated_ms>") @expose("/queries/<float:last_updated_ms>")
@expose("/queries/<int:last_updated_ms>") @expose("/queries/<int:last_updated_ms>")
@deprecated() @deprecated(new_target="api/v1/query/updated_since")
def queries(self, last_updated_ms: Union[float, int]) -> FlaskResponse: def queries(self, last_updated_ms: Union[float, int]) -> FlaskResponse:
""" """
Get the updated queries. Get the updated queries.
@@ -2530,7 +2535,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access @has_access
@event_logger.log_this @event_logger.log_this
@expose("/search_queries") @expose("/search_queries")
@deprecated() @deprecated(new_target="api/v1/query/")
def search_queries(self) -> FlaskResponse: # pylint: disable=no-self-use def search_queries(self) -> FlaskResponse: # pylint: disable=no-self-use
""" """
Search for previously run sqllab queries. Used for Sqllab Query Search Search for previously run sqllab queries. Used for Sqllab Query Search
+17 -16
View File
@@ -154,7 +154,8 @@ class BaseViz: # pylint: disable=too-many-public-methods
self.status: Optional[str] = None self.status: Optional[str] = None
self.error_msg = "" self.error_msg = ""
self.results: Optional[QueryResult] = None self.results: Optional[QueryResult] = None
self.applied_template_filters: List[str] = [] self.applied_filter_columns: List[Column] = []
self.rejected_filter_columns: List[Column] = []
self.errors: List[Dict[str, Any]] = [] self.errors: List[Dict[str, Any]] = []
self.force = force self.force = force
self._force_cached = force_cached self._force_cached = force_cached
@@ -288,7 +289,8 @@ class BaseViz: # pylint: disable=too-many-public-methods
# The datasource here can be different backend but the interface is common # The datasource here can be different backend but the interface is common
self.results = self.datasource.query(query_obj) self.results = self.datasource.query(query_obj)
self.applied_template_filters = self.results.applied_template_filters or [] self.applied_filter_columns = self.results.applied_filter_columns or []
self.rejected_filter_columns = self.results.rejected_filter_columns or []
self.query = self.results.query self.query = self.results.query
self.status = self.results.status self.status = self.results.status
self.errors = self.results.errors self.errors = self.results.errors
@@ -492,25 +494,21 @@ class BaseViz: # pylint: disable=too-many-public-methods
if "df" in payload: if "df" in payload:
del payload["df"] del payload["df"]
filters = self.form_data.get("filters", []) applied_filter_columns = self.applied_filter_columns or []
filter_columns = [flt.get("col") for flt in filters] rejected_filter_columns = self.rejected_filter_columns or []
columns = set(self.datasource.column_names)
applied_template_filters = self.applied_template_filters or []
applied_time_extras = self.form_data.get("applied_time_extras", {}) applied_time_extras = self.form_data.get("applied_time_extras", {})
applied_time_columns, rejected_time_columns = utils.get_time_filter_status( applied_time_columns, rejected_time_columns = utils.get_time_filter_status(
self.datasource, applied_time_extras self.datasource, applied_time_extras
) )
payload["applied_filters"] = [ payload["applied_filters"] = [
{"column": get_column_name(col)} {"column": get_column_name(col)} for col in applied_filter_columns
for col in filter_columns
if is_adhoc_column(col) or col in columns or col in applied_template_filters
] + applied_time_columns ] + applied_time_columns
payload["rejected_filters"] = [ payload["rejected_filters"] = [
{"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE, "column": col} {
for col in filter_columns "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
if not is_adhoc_column(col) "column": get_column_name(col),
and col not in columns }
and col not in applied_template_filters for col in rejected_filter_columns
] + rejected_time_columns ] + rejected_time_columns
if df is not None: if df is not None:
payload["colnames"] = list(df.columns) payload["colnames"] = list(df.columns)
@@ -535,8 +533,11 @@ class BaseViz: # pylint: disable=too-many-public-methods
try: try:
df = cache_value["df"] df = cache_value["df"]
self.query = cache_value["query"] self.query = cache_value["query"]
self.applied_template_filters = cache_value.get( self.applied_filter_columns = cache_value.get(
"applied_template_filters", [] "applied_filter_columns", []
)
self.rejected_filter_columns = cache_value.get(
"rejected_filter_columns", []
) )
self.status = QueryStatus.SUCCESS self.status = QueryStatus.SUCCESS
is_loaded = True is_loaded = True
+11 -7
View File
@@ -609,17 +609,21 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixin):
""" """
Chart API: Test update set new owner implicitly adds logged in owner Chart API: Test update set new owner implicitly adds logged in owner
""" """
gamma = self.get_user("gamma") gamma = self.get_user("gamma_no_csv")
alpha = self.get_user("alpha") alpha = self.get_user("alpha")
chart_id = self.insert_chart("title", [alpha.id], 1).id chart_id = self.insert_chart("title", [gamma.id], 1).id
chart_data = {"slice_name": "title1_changed", "owners": [gamma.id]} chart_data = {
self.login(username="alpha") "slice_name": (new_name := "title1_changed"),
"owners": [alpha.id],
}
self.login(username=gamma.username)
uri = f"api/v1/chart/{chart_id}" uri = f"api/v1/chart/{chart_id}"
rv = self.put_assert_metric(uri, chart_data, "put") rv = self.put_assert_metric(uri, chart_data, "put")
self.assertEqual(rv.status_code, 200) assert rv.status_code == 200
model = db.session.query(Slice).get(chart_id) model = db.session.query(Slice).get(chart_id)
self.assertIn(alpha, model.owners) assert model.slice_name == new_name
self.assertIn(gamma, model.owners) assert alpha in model.owners
assert gamma in model.owners
db.session.delete(model) db.session.delete(model)
db.session.commit() db.session.commit()
@@ -56,6 +56,7 @@ from superset.utils.core import (
AnnotationType, AnnotationType,
get_example_default_schema, get_example_default_schema,
AdhocMetricExpressionType, AdhocMetricExpressionType,
ExtraFiltersReasonType,
) )
from superset.utils.database import get_example_database, get_main_database from superset.utils.database import get_example_database, get_main_database
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
@@ -73,6 +74,12 @@ ADHOC_COLUMN_FIXTURE: AdhocColumn = {
"when gender = 'girl' then 'female' else 'other' end", "when gender = 'girl' then 'female' else 'other' end",
} }
INCOMPATIBLE_ADHOC_COLUMN_FIXTURE: AdhocColumn = {
"hasCustomLabel": True,
"label": "exciting_or_boring",
"sqlExpression": "case when genre = 'Action' then 'Exciting' else 'Boring' end",
}
class BaseTestChartDataApi(SupersetTestCase): class BaseTestChartDataApi(SupersetTestCase):
query_context_payload_template = None query_context_payload_template = None
@@ -1059,6 +1066,33 @@ class TestGetChartDataApi(BaseTestChartDataApi):
assert unique_genders == {"male", "female"} assert unique_genders == {"male", "female"}
assert result["applied_filters"] == [{"column": "male_or_female"}] assert result["applied_filters"] == [{"column": "male_or_female"}]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_chart_data_with_incompatible_adhoc_column(self):
"""
Chart data API: Test query with adhoc column that fails to run on this dataset
"""
self.login(username="admin")
request_payload = get_query_context("birth_names")
request_payload["queries"][0]["columns"] = [ADHOC_COLUMN_FIXTURE]
request_payload["queries"][0]["filters"] = [
{"col": INCOMPATIBLE_ADHOC_COLUMN_FIXTURE, "op": "IN", "val": ["Exciting"]},
{"col": ADHOC_COLUMN_FIXTURE, "op": "IN", "val": ["male", "female"]},
]
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
response_payload = json.loads(rv.data.decode("utf-8"))
result = response_payload["result"][0]
data = result["data"]
assert {column for column in data[0].keys()} == {"male_or_female", "sum__num"}
unique_genders = {row["male_or_female"] for row in data}
assert unique_genders == {"male", "female"}
assert result["applied_filters"] == [{"column": "male_or_female"}]
assert result["rejected_filters"] == [
{
"column": "exciting_or_boring",
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
}
]
@pytest.fixture() @pytest.fixture()
def physical_query_context(physical_dataset) -> Dict[str, Any]: def physical_query_context(physical_dataset) -> Dict[str, Any]:
+31 -10
View File
@@ -239,28 +239,47 @@ class TestDatasetApi(SupersetTestCase):
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
assert response["result"] == [] assert response["result"] == []
def test_get_dataset_list_gamma_owned(self): def test_get_dataset_list_gamma_has_database_access(self):
""" """
Dataset API: Test get dataset list owned by gamma Dataset API: Test get dataset list with database access
""" """
if backend() == "sqlite": if backend() == "sqlite":
return return
main_db = get_main_database()
owned_dataset = self.insert_dataset(
"ab_user", [self.get_user("gamma").id], main_db
)
self.login(username="gamma") self.login(username="gamma")
# create new dataset
main_db = get_main_database()
dataset = self.insert_dataset("ab_user", [], main_db)
# make sure dataset is not visible due to missing perms
uri = "api/v1/dataset/" uri = "api/v1/dataset/"
rv = self.get_assert_metric(uri, "get_list") rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200 assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
assert response["count"] == 1 assert response["count"] == 0
assert response["result"][0]["table_name"] == "ab_user"
db.session.delete(owned_dataset) # give database access to main db
main_db_pvm = security_manager.find_permission_view_menu(
"database_access", main_db.perm
)
gamma_role = security_manager.find_role("Gamma")
gamma_role.permissions.append(main_db_pvm)
db.session.commit()
# make sure dataset is now visible
uri = "api/v1/dataset/"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
tables = {tbl["table_name"] for tbl in response["result"]}
assert tables == {"ab_user"}
# revert gamma permission
gamma_role.permissions.remove(main_db_pvm)
db.session.delete(dataset)
db.session.commit() db.session.commit()
def test_get_dataset_related_database_gamma(self): def test_get_dataset_related_database_gamma(self):
@@ -2255,6 +2274,8 @@ class TestDatasetApi(SupersetTestCase):
assert len(new_dataset.columns) == 2 assert len(new_dataset.columns) == 2
assert new_dataset.columns[0].column_name == "id" assert new_dataset.columns[0].column_name == "id"
assert new_dataset.columns[1].column_name == "name" assert new_dataset.columns[1].column_name == "name"
db.session.delete(new_dataset)
db.session.commit()
@pytest.mark.usefixtures("create_datasets") @pytest.mark.usefixtures("create_datasets")
def test_duplicate_physical_dataset(self): def test_duplicate_physical_dataset(self):
+5 -1
View File
@@ -202,6 +202,10 @@ class TestQueryApi(SupersetTestCase):
gamma2 = self.create_user( gamma2 = self.create_user(
"gamma_2", "password", "Gamma", email="gamma2@superset.org" "gamma_2", "password", "Gamma", email="gamma2@superset.org"
) )
# Add SQLLab role to these gamma users, so they have access to queries
sqllab_role = self.get_role("sql_lab")
gamma1.roles.append(sqllab_role)
gamma2.roles.append(sqllab_role)
gamma1_client_id = self.get_random_string() gamma1_client_id = self.get_random_string()
gamma2_client_id = self.get_random_string() gamma2_client_id = self.get_random_string()
@@ -383,7 +387,7 @@ class TestQueryApi(SupersetTestCase):
sql="SELECT col1, col2 from table1", sql="SELECT col1, col2 from table1",
) )
self.login(username="gamma") self.login(username="gamma_sqllab")
arguments = {"filters": [{"col": "sql", "opr": "sw", "value": "SELECT col1"}]} arguments = {"filters": [{"col": "sql", "opr": "sw", "value": "SELECT col1"}]}
uri = f"api/v1/query/?q={prison.dumps(arguments)}" uri = f"api/v1/query/?q={prison.dumps(arguments)}"
rv = self.client.get(uri) rv = self.client.get(uri)
@@ -748,7 +748,7 @@ class TestSavedQueryApi(SupersetTestCase):
db.session.query(SavedQuery).filter(SavedQuery.created_by == admin).first() db.session.query(SavedQuery).filter(SavedQuery.created_by == admin).first()
) )
self.login(username="gamma") self.login(username="gamma_sqllab")
argument = [sample_query.id] argument = [sample_query.id]
uri = f"api/v1/saved_query/export/?q={prison.dumps(argument)}" uri = f"api/v1/saved_query/export/?q={prison.dumps(argument)}"
rv = self.client.get(uri) rv = self.client.get(uri)
+7 -1
View File
@@ -1332,8 +1332,11 @@ class TestRolePermission(SupersetTestCase):
self.assertNotIn(("menu_access", view_menu), permissions_set) self.assertNotIn(("menu_access", view_menu), permissions_set)
def assert_cannot_gamma(self, perm_set): def assert_cannot_gamma(self, perm_set):
self.assert_cannot_write("Annotation", perm_set)
self.assert_cannot_write("CssTemplate", perm_set) self.assert_cannot_write("CssTemplate", perm_set)
self.assert_cannot_menu("SQL Lab", perm_set)
self.assert_cannot_menu("CSS Templates", perm_set) self.assert_cannot_menu("CSS Templates", perm_set)
self.assert_cannot_menu("Annotation Layers", perm_set)
self.assert_cannot_menu("Manage", perm_set) self.assert_cannot_menu("Manage", perm_set)
self.assert_cannot_menu("Queries", perm_set) self.assert_cannot_menu("Queries", perm_set)
self.assert_cannot_menu("Import dashboards", perm_set) self.assert_cannot_menu("Import dashboards", perm_set)
@@ -1374,7 +1377,6 @@ class TestRolePermission(SupersetTestCase):
self.assert_can_all("Annotation", perm_set) self.assert_can_all("Annotation", perm_set)
self.assert_can_all("CssTemplate", perm_set) self.assert_can_all("CssTemplate", perm_set)
self.assert_can_all("Dataset", perm_set) self.assert_can_all("Dataset", perm_set)
self.assert_can_read("Query", perm_set)
self.assert_can_read("Database", perm_set) self.assert_can_read("Database", perm_set)
self.assertIn(("can_import_dashboards", "Superset"), perm_set) self.assertIn(("can_import_dashboards", "Superset"), perm_set)
self.assertIn(("can_this_form_post", "CsvToDatabaseView"), perm_set) self.assertIn(("can_this_form_post", "CsvToDatabaseView"), perm_set)
@@ -1504,6 +1506,8 @@ class TestRolePermission(SupersetTestCase):
self.assert_can_gamma(alpha_perm_tuples) self.assert_can_gamma(alpha_perm_tuples)
self.assert_can_alpha(alpha_perm_tuples) self.assert_can_alpha(alpha_perm_tuples)
self.assert_cannot_alpha(alpha_perm_tuples) self.assert_cannot_alpha(alpha_perm_tuples)
self.assertNotIn(("can_this_form_get", "UserInfoEditView"), alpha_perm_tuples)
self.assertNotIn(("can_this_form_post", "UserInfoEditView"), alpha_perm_tuples)
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
def test_admin_permissions(self): def test_admin_permissions(self):
@@ -1548,6 +1552,8 @@ class TestRolePermission(SupersetTestCase):
# make sure that user can create slices and dashboards # make sure that user can create slices and dashboards
self.assert_can_all("Dashboard", gamma_perm_set) self.assert_can_all("Dashboard", gamma_perm_set)
self.assert_can_read("Dataset", gamma_perm_set) self.assert_can_read("Dataset", gamma_perm_set)
self.assert_can_read("Annotation", gamma_perm_set)
self.assert_can_read("CssTemplate", gamma_perm_set)
# make sure that user can create slices and dashboards # make sure that user can create slices and dashboards
self.assert_can_all("Chart", gamma_perm_set) self.assert_can_all("Chart", gamma_perm_set)
+1
View File
@@ -991,6 +991,7 @@ class TestUtils(SupersetTestCase):
slc = self.get_slice("Girls", db.session) slc = self.get_slice("Girls", db.session)
dashboard_id = 1 dashboard_id = 1
assert slc.viz is not None
resp = self.get_json_resp( resp = self.get_json_resp(
f"/superset/explore_json/{slc.datasource_type}/{slc.datasource_id}/" f"/superset/explore_json/{slc.datasource_type}/{slc.datasource_id}/"
+ f'?form_data={{"slice_id": {slc.id}}}&dashboard_id={dashboard_id}', + f'?form_data={{"slice_id": {slc.id}}}&dashboard_id={dashboard_id}',
@@ -16,12 +16,26 @@
# under the License. # under the License.
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Any, Dict, Optional, Type
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
from sqlalchemy.types import (
Boolean,
Date,
DateTime,
DECIMAL,
Float,
Integer,
String,
TypeEngine,
)
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm from superset.utils.core import GenericDataType
from tests.unit_tests.db_engine_specs.utils import (
assert_column_spec,
assert_convert_dttm,
)
from tests.unit_tests.fixtures.common import dttm from tests.unit_tests.fixtures.common import dttm
@@ -53,3 +67,147 @@ def test_execute_connection_error() -> None:
) )
with pytest.raises(SupersetDBAPIDatabaseError) as ex: with pytest.raises(SupersetDBAPIDatabaseError) as ex:
ClickHouseEngineSpec.execute(cursor, "SELECT col1 from table1") ClickHouseEngineSpec.execute(cursor, "SELECT col1 from table1")
@pytest.mark.parametrize(
"target_type,expected_result",
[
("Date", "toDate('2019-01-02')"),
("DateTime", "toDateTime('2019-01-02 03:04:05')"),
("UnknownType", None),
],
)
def test_connect_convert_dttm(
target_type: str, expected_result: Optional[str], dttm: datetime
) -> None:
from superset.db_engine_specs.clickhouse import ClickHouseEngineSpec as spec
assert_convert_dttm(spec, target_type, expected_result, dttm)
@pytest.mark.parametrize(
"native_type,sqla_type,attrs,generic_type,is_dttm",
[
("String", String, None, GenericDataType.STRING, False),
("LowCardinality(String)", String, None, GenericDataType.STRING, False),
("Nullable(String)", String, None, GenericDataType.STRING, False),
(
"LowCardinality(Nullable(String))",
String,
None,
GenericDataType.STRING,
False,
),
("Array(UInt8)", String, None, GenericDataType.STRING, False),
("Enum('hello', 'world')", String, None, GenericDataType.STRING, False),
("Enum('UInt32', 'Bool')", String, None, GenericDataType.STRING, False),
(
"LowCardinality(Enum('hello', 'world'))",
String,
None,
GenericDataType.STRING,
False,
),
(
"Nullable(Enum('hello', 'world'))",
String,
None,
GenericDataType.STRING,
False,
),
(
"LowCardinality(Nullable(Enum('hello', 'world')))",
String,
None,
GenericDataType.STRING,
False,
),
("FixedString(16)", String, None, GenericDataType.STRING, False),
("Nullable(FixedString(16))", String, None, GenericDataType.STRING, False),
(
"LowCardinality(Nullable(FixedString(16)))",
String,
None,
GenericDataType.STRING,
False,
),
("UUID", String, None, GenericDataType.STRING, False),
("Int8", Integer, None, GenericDataType.NUMERIC, False),
("Int16", Integer, None, GenericDataType.NUMERIC, False),
("Int32", Integer, None, GenericDataType.NUMERIC, False),
("Int64", Integer, None, GenericDataType.NUMERIC, False),
("Int128", Integer, None, GenericDataType.NUMERIC, False),
("Int256", Integer, None, GenericDataType.NUMERIC, False),
("Nullable(Int256)", Integer, None, GenericDataType.NUMERIC, False),
(
"LowCardinality(Nullable(Int256))",
Integer,
None,
GenericDataType.NUMERIC,
False,
),
("UInt8", Integer, None, GenericDataType.NUMERIC, False),
("UInt16", Integer, None, GenericDataType.NUMERIC, False),
("UInt32", Integer, None, GenericDataType.NUMERIC, False),
("UInt64", Integer, None, GenericDataType.NUMERIC, False),
("UInt128", Integer, None, GenericDataType.NUMERIC, False),
("UInt256", Integer, None, GenericDataType.NUMERIC, False),
("Nullable(UInt256)", Integer, None, GenericDataType.NUMERIC, False),
(
"LowCardinality(Nullable(UInt256))",
Integer,
None,
GenericDataType.NUMERIC,
False,
),
("Float32", Float, None, GenericDataType.NUMERIC, False),
("Float64", Float, None, GenericDataType.NUMERIC, False),
("Decimal(1, 2)", DECIMAL, None, GenericDataType.NUMERIC, False),
("Decimal32(2)", DECIMAL, None, GenericDataType.NUMERIC, False),
("Decimal64(2)", DECIMAL, None, GenericDataType.NUMERIC, False),
("Decimal128(2)", DECIMAL, None, GenericDataType.NUMERIC, False),
("Decimal256(2)", DECIMAL, None, GenericDataType.NUMERIC, False),
("Bool", Boolean, None, GenericDataType.BOOLEAN, False),
("Nullable(Bool)", Boolean, None, GenericDataType.BOOLEAN, False),
("Date", Date, None, GenericDataType.TEMPORAL, True),
("Nullable(Date)", Date, None, GenericDataType.TEMPORAL, True),
("LowCardinality(Nullable(Date))", Date, None, GenericDataType.TEMPORAL, True),
("Date32", Date, None, GenericDataType.TEMPORAL, True),
("Datetime", DateTime, None, GenericDataType.TEMPORAL, True),
("Nullable(Datetime)", DateTime, None, GenericDataType.TEMPORAL, True),
(
"LowCardinality(Nullable(Datetime))",
DateTime,
None,
GenericDataType.TEMPORAL,
True,
),
("Datetime('UTC')", DateTime, None, GenericDataType.TEMPORAL, True),
("Datetime64(3)", DateTime, None, GenericDataType.TEMPORAL, True),
("Datetime64(3, 'UTC')", DateTime, None, GenericDataType.TEMPORAL, True),
],
)
def test_connect_get_column_spec(
native_type: str,
sqla_type: Type[TypeEngine],
attrs: Optional[Dict[str, Any]],
generic_type: GenericDataType,
is_dttm: bool,
) -> None:
from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec as spec
assert_column_spec(spec, native_type, sqla_type, attrs, generic_type, is_dttm)
@pytest.mark.parametrize(
"column_name,expected_result",
[
("time", "time_07cc69"),
("count", "count_e2942a"),
],
)
def test_connect_make_label_compatible(column_name: str, expected_result: str) -> None:
from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec as spec
label = spec.make_label_compatible(column_name)
assert label == expected_result
@@ -20,7 +20,7 @@ from textwrap import dedent
from typing import Any, Dict, Optional, Type from typing import Any, Dict, Optional, Type
import pytest import pytest
from sqlalchemy import column, table, types from sqlalchemy import column, table
from sqlalchemy.dialects import mssql from sqlalchemy.dialects import mssql
from sqlalchemy.dialects.mssql import DATE, NTEXT, NVARCHAR, TEXT, VARCHAR from sqlalchemy.dialects.mssql import DATE, NTEXT, NVARCHAR, TEXT, VARCHAR
from sqlalchemy.sql import select from sqlalchemy.sql import select
@@ -50,7 +50,7 @@ from tests.unit_tests.fixtures.common import dttm
) )
def test_get_column_spec( def test_get_column_spec(
native_type: str, native_type: str,
sqla_type: Type[types.TypeEngine], sqla_type: Type[TypeEngine],
attrs: Optional[Dict[str, Any]], attrs: Optional[Dict[str, Any]],
generic_type: GenericDataType, generic_type: GenericDataType,
is_dttm: bool, is_dttm: bool,
@@ -33,6 +33,7 @@ from sqlalchemy.dialects.mysql import (
TINYINT, TINYINT,
TINYTEXT, TINYTEXT,
) )
from sqlalchemy.engine.url import make_url
from superset.utils.core import GenericDataType from superset.utils.core import GenericDataType
from tests.unit_tests.db_engine_specs.utils import ( from tests.unit_tests.db_engine_specs.utils import (
@@ -99,6 +100,25 @@ def test_convert_dttm(
assert_convert_dttm(spec, target_type, expected_result, dttm) assert_convert_dttm(spec, target_type, expected_result, dttm)
@pytest.mark.parametrize(
"sqlalchemy_uri,error",
[
("mysql://user:password@host/db1?local_infile=1", True),
("mysql://user:password@host/db1?local_infile=0", True),
("mysql://user:password@host/db1", False),
],
)
def test_validate_database_uri(sqlalchemy_uri: str, error: bool) -> None:
from superset.db_engine_specs.mysql import MySQLEngineSpec
url = make_url(sqlalchemy_uri)
if error:
with pytest.raises(ValueError):
MySQLEngineSpec.validate_database_uri(url)
return
MySQLEngineSpec.validate_database_uri(url)
@patch("sqlalchemy.engine.Engine.connect") @patch("sqlalchemy.engine.Engine.connect")
def test_get_cancel_query_id(engine_mock: Mock) -> None: def test_get_cancel_query_id(engine_mock: Mock) -> None:
from superset.db_engine_specs.mysql import MySQLEngineSpec from superset.db_engine_specs.mysql import MySQLEngineSpec
-96
View File
@@ -1,96 +0,0 @@
# 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 pytest import mark
from superset.utils.memoized import memoized
@mark.unittest
class TestMemoized:
def test_memoized_on_functions(self):
watcher = {"val": 0}
@memoized
def test_function(a, b, c):
watcher["val"] += 1
return a * b * c
result1 = test_function(1, 2, 3)
result2 = test_function(1, 2, 3)
assert result1 == result2
assert watcher["val"] == 1
def test_memoized_on_methods(self):
class test_class:
def __init__(self, num):
self.num = num
self.watcher = 0
@memoized
def test_method(self, a, b, c):
self.watcher += 1
return a * b * c * self.num
instance = test_class(5)
result1 = instance.test_method(1, 2, 3)
result2 = instance.test_method(1, 2, 3)
assert result1 == result2
assert instance.watcher == 1
instance.num = 10
assert result2 == instance.test_method(1, 2, 3)
def test_memoized_on_methods_with_watches(self):
class test_class:
def __init__(self, x, y):
self.x = x
self.y = y
self.watcher = 0
@memoized(watch=("x", "y"))
def test_method(self, a, b, c):
self.watcher += 1
return a * b * c * self.x * self.y
instance = test_class(3, 12)
result1 = instance.test_method(1, 2, 3)
result2 = instance.test_method(1, 2, 3)
assert result1 == result2
assert instance.watcher == 1
result3 = instance.test_method(2, 3, 4)
assert instance.watcher == 2
result4 = instance.test_method(2, 3, 4)
assert instance.watcher == 2
assert result3 == result4
assert result3 != result1
instance.x = 1
result5 = instance.test_method(2, 3, 4)
assert instance.watcher == 3
assert result5 != result4
result6 = instance.test_method(2, 3, 4)
assert instance.watcher == 3
assert result6 == result5
instance.x = 10
instance.y = 10
result7 = instance.test_method(2, 3, 4)
assert instance.watcher == 4
assert result7 != result6
instance.x = 3
instance.y = 12
result8 = instance.test_method(1, 2, 3)
assert instance.watcher == 4
assert result1 == result8
+1 -3
View File
@@ -59,9 +59,7 @@ def test_get_metrics(mocker: MockFixture) -> None:
}, },
] ]
database.get_db_engine_spec = mocker.MagicMock( # type: ignore database.get_db_engine_spec = mocker.MagicMock(return_value=CustomSqliteEngineSpec)
return_value=CustomSqliteEngineSpec
)
assert database.get_metrics("table") == [ assert database.get_metrics("table") == [
{ {
"expression": "COUNT(DISTINCT user_id)", "expression": "COUNT(DISTINCT user_id)",