mirror of
https://github.com/apache/superset.git
synced 2026-08-29 03:21:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390be50f37 | ||
|
|
7aa6fcaf1e | ||
|
|
8dbdfb4fc6 | ||
|
|
e518b21994 | ||
|
|
e18f27e1ce | ||
|
|
2b1df8d462 | ||
|
|
9bf457dea6 | ||
|
|
cb7b790733 | ||
|
|
8bec85158c | ||
|
|
a30e4a4350 | ||
|
|
933dbbc2a2 | ||
|
|
feee3dea2f | ||
|
|
192ddf9a6d | ||
|
|
a59b96c4f5 | ||
|
|
12cd259c55 | ||
|
|
3ddc3b1d56 | ||
|
|
98ec6018df | ||
|
|
2ebd415b8a | ||
|
|
81b3e85522 | ||
|
|
fd64efd72d | ||
|
|
e39bfb255b | ||
|
|
b7301ac88a | ||
|
|
fa59b44cfe | ||
|
|
53b88da3b9 | ||
|
|
3f4fdf5f07 | ||
|
|
5a6c1b977b | ||
|
|
e8577368d3 | ||
|
|
540f8cb2d0 | ||
|
|
aae997e546 |
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -78,6 +78,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# db_engine_specs tests against real databases (testcontainers)
|
||||
name: Testcontainers
|
||||
|
||||
# Spins up real Docker containers (see tests/testcontainers/ for the current
|
||||
# dialect list) via testcontainers-python, which catches real dialect/driver
|
||||
# regressions -- the kind mocked db_engine_specs unit tests structurally
|
||||
# cannot, e.g. apache/superset#42899 (Trino emitting OFFSET before LIMIT).
|
||||
# Runs on a nightly cron (catches drift from a driver's own releases, not
|
||||
# just from Superset's changes) and on pull_request, scoped via `paths` to
|
||||
# only PRs that actually touch this test suite or the workflow itself, so
|
||||
# unrelated PRs across the repo are never affected.
|
||||
#
|
||||
# A matrix entry can set `nightly_only: true` to run only on the cron (or a
|
||||
# manual workflow_dispatch), never on pull_request -- for a dialect whose
|
||||
# image is too heavy (a multi-service cluster, a many-GB image, a slow
|
||||
# licensed installer) to justify adding its wall-clock/resource cost to
|
||||
# every PR that merely touches this suite. Omit the field entirely for a
|
||||
# normal dialect; it isn't nightly-only by default.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 5 * * *"
|
||||
workflow_dispatch: {}
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/testcontainers.yml"
|
||||
- "tests/testcontainers/**"
|
||||
- "superset/db_engine_specs/**"
|
||||
- "pyproject.toml"
|
||||
- "requirements/development.in"
|
||||
- "requirements/development.txt"
|
||||
|
||||
concurrency:
|
||||
# Scoped by ref, not just workflow name -- otherwise every PR run and the
|
||||
# nightly cron share one group, and starting the workflow on another PR
|
||||
# (or the nightly firing mid-PR-run) cancels an unrelated in-progress run.
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
testcontainers:
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# One job per dialect rather than one job for the whole suite: a
|
||||
# single slow container would otherwise inflate the wall-clock
|
||||
# time for every dialect, not just its own. Running in parallel
|
||||
# means the suite's total time is bounded by the slowest dialect,
|
||||
# not the sum of all of them. Db2's first-boot init is documented
|
||||
# upstream as notably slow (a real instance bring-up, not just a
|
||||
# process start) and untested locally here (no arm64 image), so
|
||||
# it gets a wider timeout margin than the rest until real CI data
|
||||
# says otherwise.
|
||||
- dialect: cockroachdb
|
||||
timeout: 10
|
||||
- dialect: crate
|
||||
timeout: 10
|
||||
- dialect: trino
|
||||
timeout: 10
|
||||
- dialect: mssql
|
||||
timeout: 10
|
||||
- dialect: elasticsearch
|
||||
timeout: 10
|
||||
- dialect: oracle
|
||||
timeout: 15
|
||||
- dialect: db2
|
||||
timeout: 25
|
||||
- dialect: mariadb
|
||||
timeout: 10
|
||||
- dialect: timescaledb
|
||||
timeout: 10
|
||||
- dialect: yugabytedb
|
||||
timeout: 10
|
||||
- dialect: monetdb
|
||||
timeout: 10
|
||||
- dialect: mongodb
|
||||
timeout: 10
|
||||
- dialect: postgres
|
||||
timeout: 10
|
||||
- dialect: mysql
|
||||
timeout: 10
|
||||
- dialect: clickhouse
|
||||
timeout: 10
|
||||
# StarRocks' allin1-ubuntu image brings up both FE and BE in one
|
||||
# container, which is a heavier bring-up than a single-process
|
||||
# database -- wider margin until real CI data says otherwise.
|
||||
- dialect: starrocks
|
||||
timeout: 15
|
||||
- dialect: databend
|
||||
timeout: 10
|
||||
- dialect: risingwave
|
||||
timeout: 10
|
||||
- dialect: firebird
|
||||
timeout: 10
|
||||
- dialect: ydb
|
||||
timeout: 10
|
||||
# OceanBase bootstraps a distributed-style cluster even in
|
||||
# single-node MODE=MINI -- too heavy for every PR's CI budget, so
|
||||
# it runs on the nightly cron / manual dispatch only.
|
||||
- dialect: oceanbase
|
||||
timeout: 20
|
||||
nightly_only: true
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
SUPERSET_TESTENV: true
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
# This job's matrix installs exactly one dialect's testcontainers
|
||||
# driver for exactly this job, so treat that driver as required: a
|
||||
# broken/missing import should fail the job, not silently skip to a
|
||||
# misleadingly green, zero-tests-run result. See _driver.py.
|
||||
SUPERSET_TESTCONTAINERS_STRICT: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
with:
|
||||
python-version: current
|
||||
- name: Install db2 driver (ibm-db-sa)
|
||||
# ibm-db (the db2 DBAPI) ships no Linux arm64 wheel, so it's kept out
|
||||
# of the baseline dev install (requirements/development.in) to avoid
|
||||
# breaking the multi-platform dev Docker image build. Install it here
|
||||
# instead, only for this leg of the matrix.
|
||||
if: matrix.dialect == 'db2'
|
||||
run: uv pip install --system -e .[db2]
|
||||
- name: Install oceanbase driver (oceanbase_py)
|
||||
# oceanbase_py pins sqlalchemy-utils>=0.38.3,<0.39, which conflicts
|
||||
# outright with Superset's own sqlalchemy-utils==0.42.1 pin -- kept
|
||||
# out of the baseline dev install for the same reason as db2 above.
|
||||
# Installed as its own standalone package (not via `-e .[oceanbase]`)
|
||||
# so --no-deps only skips *oceanbase_py's* dependencies -- applied
|
||||
# to `-e .[oceanbase]` instead, --no-deps blocks pip from installing
|
||||
# anything the extras marker pulls in, including oceanbase_py
|
||||
# itself, which "succeeds" without actually installing it
|
||||
# (confirmed on real CI: the install step reported success, but the
|
||||
# module was still missing). This job only needs oceanbase_py's
|
||||
# dialect module importable, not its sqlalchemy-utils dependency
|
||||
# satisfied, since nothing here calls into it.
|
||||
if: matrix.dialect == 'oceanbase'
|
||||
run: uv pip install --system --no-deps "oceanbase_py>=0.0.1.2"
|
||||
- name: Install Firebird client library (libfbclient2)
|
||||
# sqlalchemy-firebird's driver (firebird-driver) is a pure-Python
|
||||
# ctypes wrapper (its wheel is py3-none-any) that dynamically loads
|
||||
# the native Firebird client library from the host at import time
|
||||
# -- it doesn't bundle that library itself, so it has to come from
|
||||
# the system package manager, only for this leg of the matrix.
|
||||
if: matrix.dialect == 'firebird'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfbclient2
|
||||
- name: Run testcontainers db_engine_specs tests (${{ matrix.dialect }})
|
||||
# A job-level `if:` can't reference `matrix` (only github/inputs/
|
||||
# needs/vars are available there), so the nightly_only skip has to
|
||||
# live on the step instead. A dialect without `nightly_only` set
|
||||
# evaluates the left side true (unset is null, and `null != true`
|
||||
# is true) and always runs; one WITH it set only runs on the cron
|
||||
# or a manual dispatch, never on pull_request.
|
||||
if: >-
|
||||
matrix.nightly_only != true ||
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
pytest --durations-min=2 -v -m testcontainers \
|
||||
./tests/testcontainers/db_engine_specs/test_${{ matrix.dialect }}.py \
|
||||
--junit-xml=test-results/junit-testcontainers-${{ matrix.dialect }}.xml
|
||||
- name: Upload JUnit test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: junit-results-testcontainers-${{ matrix.dialect }}
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
actions-timeline:
|
||||
needs: [testcontainers]
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
+1
-1
@@ -25,7 +25,7 @@ assists people when migrating to a new version.
|
||||
## Next
|
||||
|
||||
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
|
||||
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed must `pip uninstall cockroachdb` before reinstalling the extra -- both packages register the same `cockroachdb` SQLAlchemy dialect entry point, so leaving the old one in place can still load the abandoned implementation.
|
||||
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity.
|
||||
|
||||
### MCP tool results preserve stored string values
|
||||
|
||||
|
||||
+6
-20
@@ -148,9 +148,10 @@ clickhouse = ["clickhouse-connect>=1.7.2, <2.0"]
|
||||
# 2.0). sqlalchemy-cockroachdb is the actively maintained replacement,
|
||||
# already linked from CockroachDbEngineSpec.metadata's docs_url, and
|
||||
# registers the same `cockroachdb` SQLAlchemy dialect entry point.
|
||||
# sqlalchemy-cockroachdb itself declares no DBAPI dependency (its own docs
|
||||
# require picking one), so pull in the same psycopg2-binary pin as the
|
||||
# `postgres` extra -- CockroachDB speaks the Postgres wire protocol.
|
||||
# sqlalchemy-cockroachdb depends only on SQLAlchemy itself, not on a DBAPI
|
||||
# driver, so psycopg2-binary is pinned alongside it (matching the `postgres`
|
||||
# extra) to keep this extra self-contained -- CockroachDB speaks the
|
||||
# PostgreSQL wire protocol, so psycopg2 is what actually opens connections.
|
||||
cockroachdb = ["sqlalchemy-cockroachdb>=2.0.0, <3", "psycopg2-binary==2.9.12"]
|
||||
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
|
||||
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
|
||||
@@ -195,7 +196,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
|
||||
excel-export = ["boto3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.7,<4.0",
|
||||
"mcp>=1.29.1,<2.0",
|
||||
"mcp>=1.29.1,<3.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
@@ -222,11 +223,6 @@ impala = ["impyla>=0.24.0, <0.25"]
|
||||
# superset/db_engine_specs/kusto.py's known_incompatibilities metadata.
|
||||
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
|
||||
kylin = ["kylinpy>=2.8.4, <2.9"]
|
||||
# MariaDB is a MySQL fork implementing the same wire protocol - connects via
|
||||
# the plain mysql dialect, same driver as mysql.
|
||||
mariadb = ["apache-superset[mysql]"]
|
||||
monetdb = ["sqlalchemy-monetdb>=2.1.0, <3", "pymonetdb>=1.9.1, <2"]
|
||||
mongodb = ["pymongosql>=0.7.3, <1"]
|
||||
mssql = ["pymssql>=2.3.13, <3"]
|
||||
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
|
||||
motherduck = ["apache-superset[duckdb]"]
|
||||
@@ -275,9 +271,6 @@ tdengine = [
|
||||
"taos-ws-py>=0.7.0"
|
||||
]
|
||||
teradata = ["teradatasql>=20.0.0.66"]
|
||||
# TimescaleDB is a genuine Postgres extension, not a fork - connects via the
|
||||
# plain postgresql dialect, same driver as postgres.
|
||||
timescaledb = ["apache-superset[postgres]"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
|
||||
@@ -285,18 +278,11 @@ starrocks = ["starrocks>=1.3.4, <2"]
|
||||
doris = ["pydoris>=1.2.0, <2.0.0"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1.2"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
|
||||
# YugabyteDB's YSQL layer is fully Postgres-wire compatible - connects via
|
||||
# the plain postgresql dialect, same driver as postgres.
|
||||
yugabytedb = ["apache-superset[postgres]"]
|
||||
development = [
|
||||
# no bounds for apache-superset-extensions-cli until a stable version
|
||||
"apache-superset-extensions-cli",
|
||||
"boto3",
|
||||
# 7.0.0 raises `docker.errors.DockerException: ... Not supported URL
|
||||
# scheme http+docker` against the requests/urllib3 versions pinned
|
||||
# elsewhere in this file -- breaks testcontainers (tests/testcontainers/)
|
||||
# before any container even starts. 7.2.0 is confirmed working.
|
||||
"docker>=7.2.0",
|
||||
"docker",
|
||||
"flask-testing",
|
||||
"freezegun",
|
||||
"grpcio>=1.82.1",
|
||||
|
||||
+1
-7
@@ -19,13 +19,7 @@ testpaths =
|
||||
tests
|
||||
python_files = *_test.py test_*.py *_tests.py *viz/utils.py
|
||||
# `-p no:warnings` temporarily disabled in favor of more finely tuned `filterwarnings`.
|
||||
# `not testcontainers` excludes tests/testcontainers/ by default: those spin up
|
||||
# real Docker containers, and `testpaths = tests` would otherwise pull them into
|
||||
# every plain `pytest` run. The dedicated CI job (testcontainers.yml) overrides
|
||||
# this with an explicit `-m testcontainers` to run them.
|
||||
addopts = -m "not testcontainers"
|
||||
markers =
|
||||
testcontainers: exercises a real database via testcontainers-python (needs Docker); excluded by default, see .github/workflows/testcontainers.yml
|
||||
#addopts = -p no:warnings
|
||||
asyncio_mode = auto
|
||||
|
||||
# `ignore` is effectively equivalent to `-p no:warnings`.
|
||||
|
||||
@@ -16,32 +16,5 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
-e .[development,bigquery,clickhouse,cockroachdb,crate,databend,druid,duckdb,elasticsearch,fastmcp,firebird,gevent,gsheets,monetdb,mongodb,mssql,mysql,oracle,postgres,presto,prophet,risingwave,starrocks,trino,thumbnails,ydb]
|
||||
-e .[development,bigquery,cockroachdb,druid,duckdb,fastmcp,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
|
||||
-e ./superset-extensions-cli[test]
|
||||
# testcontainers-backed db_engine_specs tests (tests/testcontainers/) --
|
||||
# see .github/workflows/testcontainers.yml
|
||||
#
|
||||
# `db2` (the `ibm-db-sa`/`ibm-db` driver) and `oceanbase` (the `oceanbase_py`
|
||||
# driver) are both deliberately left out of the baseline dev install above:
|
||||
# `ibm-db` ships no Linux arm64 wheel, breaking the multi-platform
|
||||
# (amd64+arm64) dev Docker image build; `oceanbase_py` pins
|
||||
# `sqlalchemy-utils>=0.38.3,<0.39`, which conflicts outright with Superset's
|
||||
# own `sqlalchemy-utils==0.42.1` pin -- there's no version of both that can
|
||||
# coexist in one resolved environment. Both testcontainers CI jobs install
|
||||
# their driver on demand instead, only for their own matrix leg -- see
|
||||
# .github/workflows/testcontainers.yml.
|
||||
#
|
||||
# mariadb/timescaledb/yugabytedb need no testcontainers extra of their own:
|
||||
# they reuse the postgres/mysql container classes pointed at a different
|
||||
# image, and psycopg2-binary/mysqlclient are already pulled in above via
|
||||
# the postgres/mysql extras. Plain postgres/mysql obviously need nothing
|
||||
# extra either. clickhouse and starrocks also need no testcontainers extra:
|
||||
# ClickHouseContainer has no driver import of its own (clickhouse-connect,
|
||||
# pulled in above via the clickhouse extra, is all the test needs), and
|
||||
# StarRocks has no dedicated testcontainers module at all -- its test uses
|
||||
# a generic DockerContainer plus the same mysqlclient the mysql extra
|
||||
# already provides. databend/risingwave/firebird/ydb are the same story:
|
||||
# none has a dedicated testcontainers module, so each test uses a generic
|
||||
# DockerContainer plus whatever driver its own extra above already
|
||||
# provides.
|
||||
testcontainers[cockroachdb,cratedb,mongodb,mssql,mysql,oracle,postgres,trino]>=4.15.0,<5
|
||||
|
||||
@@ -12,17 +12,10 @@
|
||||
# apache-superset
|
||||
aiofile==3.9.0
|
||||
# via py-key-value-aio
|
||||
aiohappyeyeballs==2.7.1
|
||||
# via aiohttp
|
||||
aiohttp==3.14.3
|
||||
# via ydb
|
||||
aiosignal==1.4.0
|
||||
# via aiohttp
|
||||
alembic==1.15.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-migrate
|
||||
# starrocks
|
||||
amqp==5.3.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -31,8 +24,6 @@ annotated-types==0.7.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# pydantic
|
||||
antlr4-python3-runtime==4.13.2
|
||||
# via pymongosql
|
||||
anyio==4.11.0
|
||||
# via
|
||||
# httpx
|
||||
@@ -51,12 +42,9 @@ apsw==3.50.1.0
|
||||
# shillelagh
|
||||
astroid==3.3.10
|
||||
# via pylint
|
||||
asyncmy2==0.2.21
|
||||
# via starrocks
|
||||
attrs==25.3.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# aiohttp
|
||||
# cattrs
|
||||
# cyclopts
|
||||
# jsonschema
|
||||
@@ -77,7 +65,6 @@ backports-tarfile==1.2.0
|
||||
backports-zstd==1.6.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# clickhouse-connect
|
||||
# flask-compress
|
||||
bcrypt==4.3.0
|
||||
# via
|
||||
@@ -130,11 +117,8 @@ celery==5.6.3
|
||||
certifi==2026.5.20
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# clickhouse-connect
|
||||
# elasticsearch
|
||||
# httpcore
|
||||
# httpx
|
||||
# opensearch-py
|
||||
# requests
|
||||
cffi==2.0.0
|
||||
# via
|
||||
@@ -176,8 +160,6 @@ click-repl==0.3.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# celery
|
||||
clickhouse-connect==1.7.2
|
||||
# via apache-superset
|
||||
cmdstanpy==1.1.0
|
||||
# via prophet
|
||||
colorama==0.4.6
|
||||
@@ -189,8 +171,6 @@ contourpy==1.0.7
|
||||
# via matplotlib
|
||||
coverage==7.6.8
|
||||
# via pytest-cov
|
||||
crate==2.2.1
|
||||
# via sqlalchemy-cratedb
|
||||
cron-descriptor==1.4.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -206,20 +186,14 @@ cryptography==50.0.0
|
||||
# authlib
|
||||
# google-auth
|
||||
# joserfc
|
||||
# oracledb
|
||||
# paramiko
|
||||
# pyjwt
|
||||
# pymysql
|
||||
# pyopenssl
|
||||
# secretstorage
|
||||
cycler==0.12.1
|
||||
# via matplotlib
|
||||
cyclopts==4.2.4
|
||||
# via fastmcp-slim
|
||||
databend-driver==0.34.2
|
||||
# via databend-sqlalchemy
|
||||
databend-sqlalchemy==0.5.5
|
||||
# via apache-superset
|
||||
db-dtypes==1.3.1
|
||||
# via pandas-gbq
|
||||
defusedxml==0.7.1
|
||||
@@ -242,11 +216,8 @@ dnspython==2.7.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# email-validator
|
||||
# pymongo
|
||||
docker==7.2.0
|
||||
# via
|
||||
# apache-superset
|
||||
# testcontainers
|
||||
docker==7.0.0
|
||||
# via apache-superset
|
||||
docstring-parser==0.17.0
|
||||
# via cyclopts
|
||||
docutils==0.22.2
|
||||
@@ -257,10 +228,6 @@ duckdb==1.5.5
|
||||
# duckdb-engine
|
||||
duckdb-engine==0.17.0
|
||||
# via apache-superset
|
||||
elasticsearch==7.17.13
|
||||
# via elasticsearch-dbapi
|
||||
elasticsearch-dbapi==0.2.13
|
||||
# via apache-superset
|
||||
email-validator==2.2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -270,8 +237,6 @@ et-xmlfile==2.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# openpyxl
|
||||
events==0.5
|
||||
# via opensearch-py
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp-slim
|
||||
fastmcp==3.4.7
|
||||
@@ -282,10 +247,6 @@ filelock==3.20.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# virtualenv
|
||||
firebird-base==2.0.3
|
||||
# via firebird-driver
|
||||
firebird-driver==2.0.3
|
||||
# via sqlalchemy-firebird
|
||||
flask==2.3.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -366,18 +327,12 @@ fonttools==4.60.2
|
||||
# via matplotlib
|
||||
freezegun==1.5.1
|
||||
# via apache-superset
|
||||
frozenlist==1.8.0
|
||||
# via
|
||||
# aiohttp
|
||||
# aiosignal
|
||||
future==1.0.0
|
||||
# via pyhive
|
||||
geographiclib==2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# geopy
|
||||
geojson==3.3.0
|
||||
# via sqlalchemy-cratedb
|
||||
geopy==2.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -434,7 +389,6 @@ grpcio==1.83.0
|
||||
# apache-superset
|
||||
# google-api-core
|
||||
# grpcio-status
|
||||
# ydb
|
||||
grpcio-status==1.60.1
|
||||
# via google-api-core
|
||||
gunicorn==26.2.0
|
||||
@@ -460,7 +414,6 @@ httpx==0.28.1
|
||||
# via
|
||||
# fastmcp-slim
|
||||
# mcp
|
||||
# testcontainers
|
||||
httpx-sse==0.4.1
|
||||
# via mcp
|
||||
humanize==4.12.3
|
||||
@@ -477,7 +430,6 @@ idna==3.15
|
||||
# httpx
|
||||
# requests
|
||||
# url-normalize
|
||||
# yarl
|
||||
importlib-metadata==8.7.0
|
||||
# via
|
||||
# keyring
|
||||
@@ -516,7 +468,6 @@ jmespath==1.1.0
|
||||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
# pymongosql
|
||||
joserfc==1.7.2
|
||||
# via fastmcp-slim
|
||||
jsonpath-ng==1.8.0
|
||||
@@ -549,8 +500,6 @@ kombu==5.6.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# celery
|
||||
lark==1.3.1
|
||||
# via starrocks
|
||||
lazy-object-proxy==1.10.0
|
||||
# via openapi-spec-validator
|
||||
limits==5.1.0
|
||||
@@ -558,9 +507,7 @@ limits==5.1.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-limiter
|
||||
lz4==4.4.5
|
||||
# via
|
||||
# clickhouse-connect
|
||||
# trino
|
||||
# via trino
|
||||
mako==1.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -620,10 +567,6 @@ msgspec==0.19.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-session
|
||||
multidict==6.7.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
mysqlclient==2.2.8
|
||||
# via apache-superset
|
||||
nh3==0.3.6
|
||||
@@ -662,22 +605,14 @@ openpyxl==3.1.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# pandas
|
||||
opensearch-py==2.8.0
|
||||
# via elasticsearch-dbapi
|
||||
opentelemetry-api==1.39.1
|
||||
# via fastmcp-slim
|
||||
oracledb==4.0.2
|
||||
# via
|
||||
# apache-superset
|
||||
# testcontainers
|
||||
ordered-set==4.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-limiter
|
||||
orjson==3.11.9
|
||||
# via
|
||||
# crate
|
||||
# trino
|
||||
# via trino
|
||||
packaging==25.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -685,8 +620,8 @@ packaging==25.0
|
||||
# apispec
|
||||
# db-dtypes
|
||||
# deprecation
|
||||
# docker
|
||||
# duckdb-engine
|
||||
# elasticsearch-dbapi
|
||||
# fastmcp-slim
|
||||
# google-cloud-bigquery
|
||||
# kombu
|
||||
@@ -696,8 +631,6 @@ packaging==25.0
|
||||
# pytest
|
||||
# shillelagh
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-firebird
|
||||
# ydb
|
||||
pandas==2.3.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -759,22 +692,16 @@ prompt-toolkit==3.0.51
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# click-repl
|
||||
propcache==0.5.2
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
prophet==1.4.0
|
||||
# via apache-superset
|
||||
proto-plus==1.25.0
|
||||
# via google-api-core
|
||||
protobuf==5.29.6
|
||||
# via
|
||||
# firebird-base
|
||||
# google-api-core
|
||||
# googleapis-common-protos
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
# ydb
|
||||
psutil==6.1.0
|
||||
# via
|
||||
# apache-superset
|
||||
@@ -848,24 +775,6 @@ pyjwt==2.13.0
|
||||
# mcp
|
||||
pylint==3.3.7
|
||||
# via apache-superset
|
||||
pymonetdb==1.9.1
|
||||
# via
|
||||
# apache-superset
|
||||
# sqlalchemy-monetdb
|
||||
pymongo==4.17.0
|
||||
# via
|
||||
# pymongosql
|
||||
# testcontainers
|
||||
pymongosql==0.7.3
|
||||
# via apache-superset
|
||||
pymssql==2.3.13
|
||||
# via
|
||||
# apache-superset
|
||||
# testcontainers
|
||||
pymysql==1.2.0
|
||||
# via
|
||||
# starrocks
|
||||
# testcontainers
|
||||
pynacl==1.6.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -910,13 +819,11 @@ python-dateutil==2.9.0.post0
|
||||
# botocore
|
||||
# celery
|
||||
# croniter
|
||||
# firebird-driver
|
||||
# flask-appbuilder
|
||||
# freezegun
|
||||
# google-cloud-bigquery
|
||||
# holidays
|
||||
# matplotlib
|
||||
# opensearch-py
|
||||
# pandas
|
||||
# pyhive
|
||||
# shillelagh
|
||||
@@ -927,7 +834,6 @@ python-dotenv==1.2.2
|
||||
# apache-superset
|
||||
# fastmcp-slim
|
||||
# pydantic-settings
|
||||
# testcontainers
|
||||
python-ldap==3.4.7
|
||||
# via apache-superset
|
||||
python-multipart==0.0.29
|
||||
@@ -971,7 +877,6 @@ requests==2.33.0
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# jsonschema-path
|
||||
# opensearch-py
|
||||
# pydruid
|
||||
# pyhive
|
||||
# requests-cache
|
||||
@@ -1050,9 +955,7 @@ sqlalchemy==2.0.52
|
||||
# alembic
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# databend-sqlalchemy
|
||||
# duckdb-engine
|
||||
# elasticsearch-dbapi
|
||||
# flask-appbuilder
|
||||
# flask-sqlalchemy
|
||||
# marshmallow-sqlalchemy
|
||||
@@ -1060,14 +963,7 @@ sqlalchemy==2.0.52
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-cockroachdb
|
||||
# sqlalchemy-continuum
|
||||
# sqlalchemy-cratedb
|
||||
# sqlalchemy-firebird
|
||||
# sqlalchemy-monetdb
|
||||
# sqlalchemy-risingwave
|
||||
# sqlalchemy-utils
|
||||
# starrocks
|
||||
# testcontainers
|
||||
# ydb-sqlalchemy
|
||||
sqlalchemy-bigquery==1.17.2
|
||||
# via apache-superset
|
||||
sqlalchemy-cockroachdb==2.0.4
|
||||
@@ -1076,16 +972,6 @@ sqlalchemy-continuum==1.7.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
sqlalchemy-cratedb==0.43.1
|
||||
# via
|
||||
# apache-superset
|
||||
# testcontainers
|
||||
sqlalchemy-firebird==2.2.0
|
||||
# via apache-superset
|
||||
sqlalchemy-monetdb==2.1.0
|
||||
# via apache-superset
|
||||
sqlalchemy-risingwave==2.1.0
|
||||
# via apache-superset
|
||||
sqlalchemy-utils==0.42.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -1097,7 +983,6 @@ sqlglot==30.17.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# ydb-sqlglot-plugin
|
||||
sqloxide==0.1.51
|
||||
# via apache-superset
|
||||
sse-starlette==3.0.2
|
||||
@@ -1110,8 +995,6 @@ starlette==1.3.1
|
||||
# via
|
||||
# fastmcp-slim
|
||||
# mcp
|
||||
starrocks==1.3.4
|
||||
# via apache-superset
|
||||
statsd==4.0.1
|
||||
# via apache-superset
|
||||
syntaqlite==0.9.0
|
||||
@@ -1120,8 +1003,6 @@ tabulate==0.10.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
testcontainers==4.15.0
|
||||
# via -r requirements/development.in
|
||||
tiktoken==0.14.0
|
||||
# via apache-superset
|
||||
tomli-w==1.2.0
|
||||
@@ -1133,14 +1014,10 @@ tqdm==4.67.1
|
||||
# cmdstanpy
|
||||
# prophet
|
||||
trino==0.339.0
|
||||
# via
|
||||
# apache-superset
|
||||
# testcontainers
|
||||
# via apache-superset
|
||||
typing-extensions==4.16.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# aiohttp
|
||||
# aiosignal
|
||||
# alembic
|
||||
# anyio
|
||||
# apache-superset
|
||||
@@ -1152,7 +1029,6 @@ typing-extensions==4.16.0
|
||||
# limits
|
||||
# mcp
|
||||
# opentelemetry-api
|
||||
# oracledb
|
||||
# py-key-value-aio
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
@@ -1161,7 +1037,6 @@ typing-extensions==4.16.0
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
# starlette
|
||||
# testcontainers
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
# via
|
||||
@@ -1189,22 +1064,13 @@ urllib3==2.7.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# botocore
|
||||
# clickhouse-connect
|
||||
# crate
|
||||
# docker
|
||||
# elasticsearch
|
||||
# opensearch-py
|
||||
# requests
|
||||
# requests-cache
|
||||
# testcontainers
|
||||
uvicorn==0.37.0
|
||||
# via
|
||||
# fastmcp-slim
|
||||
# mcp
|
||||
verlib2==0.3.2
|
||||
# via
|
||||
# crate
|
||||
# sqlalchemy-cratedb
|
||||
vine==5.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -1238,7 +1104,6 @@ wrapt==1.17.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# deprecated
|
||||
# testcontainers
|
||||
wtforms==3.2.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -1259,18 +1124,6 @@ xlsxwriter==3.2.9
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# pandas
|
||||
yarl==1.24.5
|
||||
# via aiohttp
|
||||
ydb==3.31.4
|
||||
# via
|
||||
# ydb-dbapi
|
||||
# ydb-sqlalchemy
|
||||
ydb-dbapi==0.1.23
|
||||
# via ydb-sqlalchemy
|
||||
ydb-sqlalchemy==0.1.22
|
||||
# via apache-superset
|
||||
ydb-sqlglot-plugin==0.2.8
|
||||
# via apache-superset
|
||||
zipp==3.23.0
|
||||
# via importlib-metadata
|
||||
zope-event==5.0
|
||||
|
||||
Generated
+9
-9
@@ -108,7 +108,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -234,7 +234,7 @@
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
|
||||
"eslint-plugin-storybook": "10.5.10",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -20208,9 +20208,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.1.tgz",
|
||||
"integrity": "sha512-oOhQTYhor88Xp8RVytq25tvBfiAjU0r9SCDC51Qop+3Wg5BR1xGMAkM+/dV4MZbcMhdaU1L9bkv6LC95JmiTig==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.2.tgz",
|
||||
"integrity": "sha512-HgYol2zhH3KbnW9Q4FY/FcIINbsYVL6rwQESChqBC2s9SLWtw1wg05+J7SCrr3BfQpNY2ReYhf8xjpd2JhKJOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -28488,9 +28488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
|
||||
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.29.0.tgz",
|
||||
"integrity": "sha512-Fnh1WLsZMfihwRZY5scp456iQuZo9G97tTpb26bf/Ejsi/L7O+4dE9+I03VoOor2ul3DEOp6F2P3273omyVsNw==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -43490,7 +43490,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^9.0.0"
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -311,7 +311,7 @@
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
|
||||
"eslint-plugin-storybook": "10.5.10",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
|
||||
@@ -65,6 +65,7 @@ export type AntdExposedProps = Pick<
|
||||
| 'onOpenChange'
|
||||
| 'optionRender'
|
||||
| 'placeholder'
|
||||
| 'prefix'
|
||||
| 'showArrow'
|
||||
| 'showSearch'
|
||||
| 'tokenSeparators'
|
||||
|
||||
@@ -119,6 +119,20 @@ export function retrieveErrorMessage(
|
||||
return statusError || parseStringResponse(str);
|
||||
}
|
||||
|
||||
function getFirstValidationError(message: JsonObject): string | undefined {
|
||||
const [firstError] = Object.values(message);
|
||||
|
||||
if (typeof firstError === 'string') {
|
||||
return firstError;
|
||||
}
|
||||
|
||||
if (Array.isArray(firstError)) {
|
||||
return firstError.find((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
|
||||
let error = { ...responseJson };
|
||||
// Backwards compatibility for old error renderers with the new error object
|
||||
@@ -126,13 +140,12 @@ export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
|
||||
error.error = error.description = error.errors[0].message;
|
||||
error.link = error.errors[0]?.extra?.link;
|
||||
}
|
||||
// Marshmallow field validation returns the error message in the format
|
||||
// of { message: { field1: [msg1, msg2], field2: [msg], } }
|
||||
// Marshmallow field validation returns arrays for string messages, but
|
||||
// serializes lazy translation messages as strings instead.
|
||||
if (!error.error && error.message) {
|
||||
if (typeof error.message === 'object') {
|
||||
error.error =
|
||||
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
|
||||
t('Invalid input');
|
||||
getFirstValidationError(error.message) || t('Invalid input');
|
||||
}
|
||||
if (typeof error.message === 'string') {
|
||||
if (checkForHtml(error.message)) {
|
||||
|
||||
@@ -244,6 +244,24 @@ test('parseErrorJson with message', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parseErrorJson preserves string-valued validation messages', () => {
|
||||
const calculatedColumnError =
|
||||
'Custom SQL fields cannot be parsed as a single SQL statement.';
|
||||
|
||||
expect(
|
||||
parseErrorJson({
|
||||
message: {
|
||||
'columns.0.expression': calculatedColumnError,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
message: {
|
||||
'columns.0.expression': calculatedColumnError,
|
||||
},
|
||||
error: calculatedColumnError,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseErrorJson with HTML message', () => {
|
||||
expect(
|
||||
parseErrorJson({
|
||||
|
||||
@@ -47,6 +47,9 @@ const getCrossFilterDataMask =
|
||||
) =>
|
||||
(value: string) => {
|
||||
const selected = Object.values(selectedValues);
|
||||
if (!labelMap[value] && !selected.includes(value)) {
|
||||
return undefined;
|
||||
}
|
||||
let values: string[];
|
||||
if (selected.includes(value)) {
|
||||
values = selected.filter(v => v !== value);
|
||||
|
||||
@@ -180,3 +180,46 @@ test('cross-filter does nothing when emitCrossFilters is false', () => {
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cross-filter does nothing when name is missing from labelMap', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const props = buildProps({
|
||||
groupby: ['topics'],
|
||||
labelMap: {
|
||||
cancellations: ['cancellations'],
|
||||
},
|
||||
selectedValues: {},
|
||||
setDataMask,
|
||||
});
|
||||
|
||||
const handlers = allEventHandlers(props);
|
||||
// e.g. Pie "Other" category is not present in labelMap
|
||||
handlers.click({ name: 'Other' });
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cross-filter still deselects a previously selected value that is missing from labelMap', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const props = buildProps({
|
||||
groupby: ['topics'],
|
||||
labelMap: {
|
||||
cancellations: ['cancellations'],
|
||||
},
|
||||
// "Other" was selected before it dropped out of labelMap (e.g. a stale
|
||||
// cross-filter from an earlier render or dashboard state).
|
||||
selectedValues: { 0: 'Other' },
|
||||
setDataMask,
|
||||
});
|
||||
|
||||
const handlers = allEventHandlers(props);
|
||||
handlers.click({ name: 'Other' });
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^9.0.0"
|
||||
|
||||
+17
@@ -43,6 +43,23 @@ describe('SaveDatasetActionButton', () => {
|
||||
expect(saveDatasetBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('disables only the dataset button when canSaveDataset is false', () => {
|
||||
const onSaveAsExplore = jest.fn();
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={onSaveAsExplore}
|
||||
canSaveDataset={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Saving the query needs no results.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the query did not run successfully', async () => {
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
createStore,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
@@ -63,6 +65,12 @@ beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// In-body restores are skipped when an assertion throws, leaking a
|
||||
// configured spy into later tests.
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
@@ -518,6 +526,39 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces the error and keeps the modal open when saving fails', async () => {
|
||||
// The chart-payload step's toast was built but never dispatched, so a
|
||||
// failure there was silent.
|
||||
const postFormData = jest.spyOn(
|
||||
require('src/explore/exploreUtils/formData'),
|
||||
'postFormData',
|
||||
);
|
||||
postFormData.mockRejectedValue(new Error('Boom'));
|
||||
const onHide = jest.fn();
|
||||
const store = createStore({ user }, reducerIndex);
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} onHide={onHide} />, { store });
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue(/unimportant/i), {
|
||||
target: { value: 'my dataset' },
|
||||
});
|
||||
userEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// `createStore` builds its reducer map at runtime, so state isn't typed.
|
||||
const toasts = () =>
|
||||
(
|
||||
store.getState() as unknown as {
|
||||
messageToasts: { toastType: string }[];
|
||||
}
|
||||
).messageToasts;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
expect(toasts()[0].toastType).toBe('DANGER_TOAST');
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearDatasetCache is imported and available', () => {
|
||||
const { clearDatasetCache } = require('src/utils/cachedSupersetGet');
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ import type Subject from 'src/types/Subject';
|
||||
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
|
||||
|
||||
// Derived so it can't drift from what `getClientErrorObject` accepts.
|
||||
type SaveErrorSource = Parameters<typeof getClientErrorObject>[0];
|
||||
|
||||
interface QueryDatabase {
|
||||
id?: number;
|
||||
}
|
||||
@@ -391,9 +394,18 @@ export const SaveDatasetModal = ({
|
||||
setDatasetName(getDefaultDatasetName());
|
||||
onHide();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error?: SaveErrorSource) => {
|
||||
setLoading(false);
|
||||
addDangerToast(t('An error occurred saving dataset'));
|
||||
// `createDatasource` already toasted the server's message and rejects
|
||||
// with nothing; only the chart-payload step needs its own.
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
getClientErrorObject(error).then(e =>
|
||||
dispatch(
|
||||
addDangerToast(e.error || t('An error occurred saving dataset')),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import SaveQuery from 'src/SqlLab/components/SaveQuery';
|
||||
import { initialState, databases } from 'src/SqlLab/fixtures';
|
||||
|
||||
const RESULT_COLUMNS = [{ column_name: 'col', type: 'STRING' }];
|
||||
|
||||
const mockedProps = {
|
||||
queryEditorId: '123',
|
||||
animation: false,
|
||||
@@ -35,7 +37,6 @@ const mockedProps = {
|
||||
onSave: () => {},
|
||||
saveQueryWarning: null,
|
||||
columns: [],
|
||||
canSaveDataset: true,
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
@@ -60,8 +61,31 @@ const splitSaveBtnProps = {
|
||||
...mockedProps.database,
|
||||
allows_virtual_table_explore: true,
|
||||
},
|
||||
columns: RESULT_COLUMNS,
|
||||
};
|
||||
|
||||
const EDITOR_SQL = 'SELECT * FROM t';
|
||||
|
||||
const stateWithLatestQuery = ({
|
||||
id,
|
||||
state,
|
||||
sql = EDITOR_SQL,
|
||||
}: {
|
||||
id: string;
|
||||
state: string;
|
||||
sql?: string;
|
||||
}) => ({
|
||||
...mockState,
|
||||
sqlLab: {
|
||||
...mockState.sqlLab,
|
||||
queryEditors: mockState.sqlLab.queryEditors.map(qe => ({
|
||||
...qe,
|
||||
latestQueryId: id,
|
||||
})),
|
||||
queries: { [id]: { id, state, sql } },
|
||||
},
|
||||
});
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureStore(middlewares);
|
||||
|
||||
@@ -97,6 +121,71 @@ describe('SavedQuery', () => {
|
||||
expect(saveBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" until the query has run successfully', () => {
|
||||
// Without a successful run the save can only fail server-side.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'failed' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
// Saving the query itself is unaffected.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when no query has been run at all', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the SQL changed after a successful run', () => {
|
||||
// The run succeeded, but not for what is in the editor now -- and it is
|
||||
// the editor's SQL that gets saved.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(
|
||||
stateWithLatestQuery({
|
||||
id: 'qid-1',
|
||||
state: 'success',
|
||||
sql: 'SELECT 1 AS ran_earlier',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the successful query returned no columns', () => {
|
||||
// e.g. a DDL/DML statement -- there is nothing to introspect into a dataset.
|
||||
render(<SaveQuery {...splitSaveBtnProps} columns={[]} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('enables "Save dataset" once the query has succeeded', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /save dataset/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('renders a save query modal when user clicks save button', () => {
|
||||
render(<SaveQuery {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
@@ -234,7 +323,7 @@ describe('SavedQuery', () => {
|
||||
test('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
@@ -248,7 +337,7 @@ describe('SavedQuery', () => {
|
||||
test('renders the save dataset modal UI', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
userEvent.click(saveDatasetMenuItem);
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useMemo, ChangeEvent } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Query, QueryState } from '@superset-ui/core';
|
||||
import type { DatabaseObject } from 'src/features/databases/types';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
@@ -37,7 +39,7 @@ import {
|
||||
} from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import { QueryEditor } from 'src/SqlLab/types';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
import {
|
||||
LOG_ACTIONS_SQLLAB_CREATE_CHART,
|
||||
@@ -52,7 +54,6 @@ interface SaveQueryProps {
|
||||
onUpdate: (arg0: QueryPayload, id: string) => void;
|
||||
saveQueryWarning: string | null;
|
||||
database: Partial<DatabaseObject> | undefined;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
export type QueryPayload = {
|
||||
@@ -82,7 +83,6 @@ const SaveQuery = ({
|
||||
saveQueryWarning,
|
||||
database,
|
||||
columns,
|
||||
canSaveDataset,
|
||||
}: SaveQueryProps) => {
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'autorun',
|
||||
@@ -113,6 +113,17 @@ const SaveQuery = ({
|
||||
const [label, setLabel] = useState<string>(defaultLabel);
|
||||
const [showSave, setShowSave] = useState<boolean>(false);
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
// Saving a dataset runs the SQL to introspect columns, so it needs a
|
||||
// successful run of the SQL being saved that produced at least one column
|
||||
// -- editing after a run invalidates it, and running a selection only
|
||||
// validates that selection.
|
||||
const latestQuery = useSelector<SqlLabRootState, Query | undefined>(
|
||||
({ sqlLab }) => sqlLab.queries[queryEditor.latestQueryId || ''],
|
||||
);
|
||||
const canSaveDataset =
|
||||
latestQuery?.state === QueryState.Success &&
|
||||
latestQuery.sql === queryEditor.sql &&
|
||||
columns.length > 0;
|
||||
const isSaved = !!query.remoteId;
|
||||
const isLabelEmpty = label.trim().length === 0;
|
||||
const canExploreDatabase = !!database?.allows_virtual_table_explore;
|
||||
|
||||
@@ -355,25 +355,32 @@ describe('SqlEditor', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// findByRole('button', { name }) walks every stylesheet rule via nwsapi to
|
||||
// compute the accessible name, which can crash on an unrelated antd Tabs
|
||||
// "more" button style; findByLabelText matches the same aria-label without
|
||||
// that traversal.
|
||||
test('enables the save dataset button when the latest query succeeded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
sql: mockedProps.queryEditor.sql,
|
||||
});
|
||||
expect(await findByLabelText('Save dataset')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the latest query failed', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Failed,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
expect(await findByLabelText('Save dataset')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the results are not loaded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
expect(await findByLabelText('Save dataset')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders an Extension if provided', async () => {
|
||||
|
||||
@@ -868,7 +868,6 @@ const SqlEditor: FC<Props> = ({
|
||||
}
|
||||
saveQueryWarning={saveQueryWarning}
|
||||
database={database}
|
||||
canSaveDataset={successful && resultColumns.length > 0}
|
||||
/>
|
||||
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
|
||||
</>
|
||||
|
||||
@@ -53,6 +53,22 @@ test('RowCountLabel renders limit with danger and tooltip', async () => {
|
||||
expect(tooltip).toHaveTextContent('The row limit');
|
||||
});
|
||||
|
||||
test('RowCountLabel uses a caller-provided limitReachedMessage instead of the default', async () => {
|
||||
render(
|
||||
<RowCountLabel
|
||||
rowcount={100}
|
||||
limit={100}
|
||||
limitReachedMessage="Custom limit message"
|
||||
/>,
|
||||
);
|
||||
const expectedText = '100 rows';
|
||||
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
||||
userEvent.hover(screen.getByText(expectedText));
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent('Custom limit message');
|
||||
expect(tooltip).not.toHaveTextContent('The row limit set for the chart');
|
||||
});
|
||||
|
||||
test('RowCountLabel renders loading', () => {
|
||||
render(<RowCountLabel loading />);
|
||||
const expectedText = 'Loading...';
|
||||
|
||||
@@ -26,6 +26,9 @@ type RowCountLabelProps = {
|
||||
limit?: number;
|
||||
loading?: boolean;
|
||||
label?: JSX.Element;
|
||||
// Overrides the default "chart" wording for panes (e.g. samples) where the
|
||||
// limit reached isn't the chart's own row_limit.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
};
|
||||
|
||||
const limitReachedMsg = t(
|
||||
@@ -33,7 +36,13 @@ const limitReachedMsg = t(
|
||||
);
|
||||
|
||||
export default function RowCountLabel(props: RowCountLabelProps) {
|
||||
const { rowcount = 0, limit = null, loading, label } = props;
|
||||
const {
|
||||
rowcount = 0,
|
||||
limit = null,
|
||||
loading,
|
||||
label,
|
||||
limitReachedMessage,
|
||||
} = props;
|
||||
const limitReached = limit && rowcount >= limit;
|
||||
const type =
|
||||
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
|
||||
@@ -50,7 +59,10 @@ export default function RowCountLabel(props: RowCountLabelProps) {
|
||||
</Label>
|
||||
);
|
||||
return limitReached ? (
|
||||
<Tooltip id="tt-rowcount-tooltip" title={<span>{limitReachedMsg}</span>}>
|
||||
<Tooltip
|
||||
id="tt-rowcount-tooltip"
|
||||
title={<span>{limitReachedMessage ?? limitReachedMsg}</span>}
|
||||
>
|
||||
{label || labelText}
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -117,7 +117,7 @@ const StyledDiv = styled.div`
|
||||
${
|
||||
isMobileConsumptionEnabled()
|
||||
? `@media (max-width: ${theme.screenSMMax}px) {
|
||||
[data-test='slice-header'] .header-title {
|
||||
.slice-header .header-title {
|
||||
-webkit-line-clamp: unset;
|
||||
display: block;
|
||||
white-space: normal;
|
||||
|
||||
@@ -79,6 +79,15 @@ type PropertiesModalProps = {
|
||||
addSuccessToast: (message: string) => void;
|
||||
addDangerToast: (message: string) => void;
|
||||
onlyApply?: boolean;
|
||||
renderExtraFields?: (context: {
|
||||
assetId: number;
|
||||
assetType: 'dashboard';
|
||||
accessorCount: number;
|
||||
}) => {
|
||||
content: React.ReactNode;
|
||||
saveDisabled?: boolean;
|
||||
saveTooltip?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DashboardInfo = {
|
||||
@@ -107,6 +116,7 @@ const PropertiesModal = ({
|
||||
onlyApply = false,
|
||||
onSubmit = () => {},
|
||||
show = false,
|
||||
renderExtraFields,
|
||||
}: PropertiesModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [form] = Form.useForm();
|
||||
@@ -123,6 +133,17 @@ const PropertiesModal = ({
|
||||
});
|
||||
const [editors, setEditors] = useState<Subject[]>([]);
|
||||
const [viewers, setViewers] = useState<Subject[]>([]);
|
||||
|
||||
const extraFields = useMemo(
|
||||
() =>
|
||||
renderExtraFields?.({
|
||||
assetId: dashboardId,
|
||||
assetType: 'dashboard',
|
||||
accessorCount: editors.length + viewers.length,
|
||||
}),
|
||||
[renderExtraFields, dashboardId, editors.length, viewers.length],
|
||||
);
|
||||
|
||||
const saveLabel = onlyApply ? t('Apply') : t('Save');
|
||||
const [tags, setTags] = useState<TagType[]>([]);
|
||||
const [customCss, setCustomCss] = useState('');
|
||||
@@ -698,15 +719,21 @@ const PropertiesModal = ({
|
||||
}}
|
||||
title={t('Dashboard properties')}
|
||||
isEditMode
|
||||
saveDisabled={dashboardInfo?.isManagedExternally || hasErrors}
|
||||
saveDisabled={
|
||||
dashboardInfo?.isManagedExternally ||
|
||||
hasErrors ||
|
||||
extraFields?.saveDisabled
|
||||
}
|
||||
saveLoading={isApplying}
|
||||
contentLoading={isLoading}
|
||||
errorTooltip={
|
||||
dashboardInfo?.isManagedExternally
|
||||
? t(
|
||||
"This dashboard is managed externally, and can't be edited in Superset",
|
||||
)
|
||||
: errorTooltip
|
||||
extraFields?.saveDisabled && extraFields?.saveTooltip
|
||||
? extraFields.saveTooltip
|
||||
: dashboardInfo?.isManagedExternally
|
||||
? t(
|
||||
"This dashboard is managed externally, and can't be edited in Superset",
|
||||
)
|
||||
: errorTooltip
|
||||
}
|
||||
saveText={saveLabel}
|
||||
wrapProps={{ 'data-test': 'properties-edit-modal' }}
|
||||
@@ -769,6 +796,7 @@ const PropertiesModal = ({
|
||||
onChangeViewers={handleOnChangeViewers}
|
||||
onChangeTags={handleChangeTags}
|
||||
onClearTags={handleClearTags}
|
||||
renderExtraFields={extraFields}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@ interface AccessSectionProps {
|
||||
onChangeViewers: (viewers: SubjectPickerValue[]) => void;
|
||||
onChangeTags: (tags: { label: string; value: number }[]) => void;
|
||||
onClearTags: () => void;
|
||||
renderExtraFields?: {
|
||||
content: React.ReactNode;
|
||||
saveDisabled?: boolean;
|
||||
saveTooltip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const AccessSection = ({
|
||||
@@ -49,6 +54,7 @@ const AccessSection = ({
|
||||
onChangeViewers,
|
||||
onChangeTags,
|
||||
onClearTags,
|
||||
renderExtraFields,
|
||||
}: AccessSectionProps) => {
|
||||
const enableViewers = isFeatureEnabled(FeatureFlag.EnableViewers);
|
||||
|
||||
@@ -134,6 +140,7 @@ const AccessSection = ({
|
||||
/>
|
||||
</ModalFormField>
|
||||
)}
|
||||
{renderExtraFields?.content}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -208,6 +208,18 @@ test('Should render', () => {
|
||||
expect(screen.getByTestId('slice-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should expose a class hook, not just data-test, for fullscreen styling', () => {
|
||||
const props = createProps();
|
||||
render(<SliceHeader {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState,
|
||||
});
|
||||
// The production build strips data-test attributes, so CSS that targets the
|
||||
// header must hang off a class instead.
|
||||
expect(screen.getByTestId('slice-header')).toHaveClass('slice-header');
|
||||
});
|
||||
|
||||
test('Should render - default props', () => {
|
||||
const props = createProps();
|
||||
|
||||
|
||||
@@ -270,7 +270,11 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartHeaderStyles data-test="slice-header" ref={ref}>
|
||||
<ChartHeaderStyles
|
||||
className="slice-header"
|
||||
data-test="slice-header"
|
||||
ref={ref}
|
||||
>
|
||||
<div className="header-title" ref={headerRef}>
|
||||
<Tooltip title={headerTooltip}>
|
||||
{/* this div ensures the hover event triggers correctly and prevents flickering */}
|
||||
|
||||
@@ -19,20 +19,13 @@
|
||||
import { css, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
export const fullscreenStyles = (theme: SupersetTheme) => css`
|
||||
[data-test='dashboard-component-chart-holder']:fullscreen {
|
||||
.dashboard-component-chart-holder:fullscreen {
|
||||
background-color: ${theme.colorBgBase};
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${theme.sizeUnit * 4}px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
z-index: ${theme.zIndexPopupBase};
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
/* Ensure children take up available space */
|
||||
.dashboard-chart,
|
||||
@@ -58,13 +51,8 @@ export const fullscreenStyles = (theme: SupersetTheme) => css`
|
||||
}
|
||||
}
|
||||
|
||||
/* Interaction and Header fixes */
|
||||
[data-test='dashboard-component-chart-holder']:fullscreen * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-test='dashboard-component-chart-holder']:fullscreen
|
||||
[data-test='slice-header'] {
|
||||
/* Keep the header above the chart it shares the fullscreen layer with */
|
||||
.dashboard-component-chart-holder:fullscreen .slice-header {
|
||||
z-index: ${theme.zIndexPopupBase};
|
||||
position: relative;
|
||||
}
|
||||
|
||||
+11
-7
@@ -337,13 +337,17 @@ const ChartHolder = ({
|
||||
)}
|
||||
>
|
||||
<AntdThemeProvider
|
||||
getPopupContainer={(triggerNode: HTMLElement) =>
|
||||
document.fullscreenElement
|
||||
? (triggerNode?.closest?.(
|
||||
'[data-test="dashboard-component-chart-holder"]',
|
||||
) as HTMLElement) || document.body
|
||||
: document.body
|
||||
}
|
||||
getPopupContainer={(triggerNode?: HTMLElement) => {
|
||||
// Only the fullscreen element's subtree is painted, so popups
|
||||
// have to be portaled into it rather than to document.body.
|
||||
// Resolve it directly instead of matching a selector: the
|
||||
// production build strips data-test attributes.
|
||||
const fullscreenElement =
|
||||
document.fullscreenElement as HTMLElement | null;
|
||||
return triggerNode && fullscreenElement?.contains(triggerNode)
|
||||
? fullscreenElement
|
||||
: document.body;
|
||||
}}
|
||||
>
|
||||
{!editMode && (
|
||||
<AnchorLink
|
||||
|
||||
+26
@@ -97,3 +97,29 @@ test('does not render DeckglLayerVisibilityTooltip for standard filter type', ()
|
||||
screen.queryByTestId('deckgl-layer-visibility-tooltip-icon'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not mark a defaultToFirstItem-only filter as required', () => {
|
||||
render(
|
||||
<FilterControl
|
||||
filter={{
|
||||
...nativeFilter,
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
}}
|
||||
onFilterSelectionChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('*')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('marks an enableEmptyFilter filter as required', () => {
|
||||
render(
|
||||
<FilterControl
|
||||
filter={{
|
||||
...nativeFilter,
|
||||
controlValues: { enableEmptyFilter: true },
|
||||
}}
|
||||
onFilterSelectionChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
+1
-3
@@ -65,9 +65,7 @@ const FilterControl = ({
|
||||
isFilterInScope(filter) &&
|
||||
checkIsMissingRequiredValue(filter, filter.dataMask?.filterState);
|
||||
const validateStatus = isMissingRequiredValue ? 'error' : undefined;
|
||||
const isRequired =
|
||||
!!filter.controlValues?.enableEmptyFilter ||
|
||||
!!filter.controlValues?.defaultToFirstItem;
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
const inverseSelection = !!filter.controlValues?.inverseSelection;
|
||||
|
||||
const {
|
||||
|
||||
@@ -211,6 +211,16 @@ test('checkIsMissingRequiredValue returns false for non-required filter with und
|
||||
expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false);
|
||||
});
|
||||
|
||||
test('checkIsMissingRequiredValue returns false when only defaultToFirstItem is set', () => {
|
||||
const filter = createFilter('test-filter', {
|
||||
enableEmptyFilter: false,
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
});
|
||||
|
||||
expect(checkIsMissingRequiredValue(filter, { value: null })).toBe(false);
|
||||
expect(checkIsMissingRequiredValue(filter, { value: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
test('checkIsMissingRequiredValue returns falsy for filter without controlValues', () => {
|
||||
const filter = { id: 'test-filter' } as Filter;
|
||||
const filterState: FilterState = { value: undefined };
|
||||
@@ -299,6 +309,48 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
|
||||
);
|
||||
});
|
||||
|
||||
test('checkIsApplyDisabled enables Apply after clearing a cascading defaultToFirstItem child', () => {
|
||||
// Regression: a child filter that is dependent on a parent and configured with
|
||||
// "Select first filter value by default" but NOT "Filter value is required"
|
||||
// must stay clearable — clearing it may not disable Apply.
|
||||
const parent = createFilter('parent', {
|
||||
enableEmptyFilter: true,
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
});
|
||||
const child = createFilter('child', {
|
||||
enableEmptyFilter: false,
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
});
|
||||
const dataMaskSelected: DataMaskStateWithId = {
|
||||
parent: {
|
||||
id: 'parent',
|
||||
filterState: { value: ['USA'] },
|
||||
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
|
||||
},
|
||||
child: {
|
||||
id: 'child',
|
||||
filterState: { value: null },
|
||||
extraFormData: {},
|
||||
},
|
||||
};
|
||||
const dataMaskApplied: DataMaskStateWithId = {
|
||||
parent: {
|
||||
id: 'parent',
|
||||
filterState: { value: ['USA'] },
|
||||
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
|
||||
},
|
||||
child: {
|
||||
id: 'child',
|
||||
filterState: { value: ['CA'] },
|
||||
extraFormData: createExtraFormDataWithFilter('state', ['CA']),
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, [parent, child]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
|
||||
// Regression: when a required filter's default isn't applied (Applied missing
|
||||
// the entry) and the user types a value, Selected gains an entry Applied
|
||||
|
||||
@@ -48,9 +48,10 @@ export const checkIsMissingRequiredValue = (
|
||||
filter: FilterElement,
|
||||
filterState?: FilterState,
|
||||
) => {
|
||||
const isRequired =
|
||||
!!filter.controlValues?.enableEmptyFilter ||
|
||||
!!filter.controlValues?.defaultToFirstItem;
|
||||
// Only `enableEmptyFilter` ("Filter value is required") makes a value
|
||||
// mandatory. `defaultToFirstItem` merely seeds an initial selection, so a
|
||||
// filter cleared by the user must stay clearable, with Apply enabled.
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
|
||||
if (!isRequired) return false;
|
||||
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
userEvent,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import Control, { ControlProps } from 'src/explore/components/Control';
|
||||
|
||||
const defaultProps: ControlProps = {
|
||||
@@ -77,3 +83,72 @@ test('call setControlValue if isVisible is false', async () => {
|
||||
expect(defaultProps.actions.setControlValue).toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the description icon while the control is hovered', async () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.hover(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.unhover(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows the description icon while the control has keyboard focus', () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.focus(screen.getByRole('checkbox'));
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
expect(infoIcon).toBeInTheDocument();
|
||||
|
||||
fireEvent.blur(screen.getByRole('checkbox'), { relatedTarget: infoIcon });
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.blur(infoIcon, { relatedTarget: document.body });
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps the description icon visible when the pointer leaves a focused control', () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.focus(screen.getByRole('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useCallback, useState, useEffect } from 'react';
|
||||
import { ReactNode, useCallback, useState, useEffect, FocusEvent } from 'react';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import {
|
||||
ControlType,
|
||||
@@ -70,7 +70,18 @@ export default function Control(props: ControlProps) {
|
||||
} = props;
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const wasVisible = usePrevious(isVisible);
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
setFocused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: any, errors: any[]) => setControlValue(name, value, errors),
|
||||
[name, setControlValue],
|
||||
@@ -119,9 +130,15 @@ export default function Control(props: ControlProps) {
|
||||
style={hidden ? { display: 'none' } : undefined}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<ControlComponent onChange={onChange} hovered={hovered} {...props} />
|
||||
<ControlComponent
|
||||
onChange={onChange}
|
||||
hovered={hovered || focused}
|
||||
{...props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</StyledControl>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import ControlHeader from './ControlHeader';
|
||||
|
||||
const description = 'This control filters the whole chart.';
|
||||
|
||||
test('does not render the description icon until the control is hovered', () => {
|
||||
const { rerender } = render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('notifies onDescriptionHoverChange when the info icon is hovered', async () => {
|
||||
const onDescriptionHoverChange = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
onDescriptionHoverChange={onDescriptionHoverChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
await userEvent.hover(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
|
||||
|
||||
await userEvent.unhover(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('notifies onDescriptionHoverChange when the info icon is focused', () => {
|
||||
const onDescriptionHoverChange = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
onDescriptionHoverChange={onDescriptionHoverChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
fireEvent.focus(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
|
||||
|
||||
fireEvent.blur(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('activates tooltipOnClick from the keyboard', () => {
|
||||
const tooltipOnClick = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
tooltipOnClick={tooltipOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Show info tooltip' }), {
|
||||
key: 'Enter',
|
||||
});
|
||||
expect(tooltipOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export type ControlHeaderProps = {
|
||||
tooltipOnClick?: () => void;
|
||||
warning?: string;
|
||||
danger?: string;
|
||||
onDescriptionHoverChange?: (hovered: boolean) => void;
|
||||
// Allow extra props from control spread patterns (e.g. {...this.props})
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -71,6 +72,7 @@ const ControlHeader: FC<ControlHeaderProps> = ({
|
||||
tooltipOnClick = () => {},
|
||||
warning,
|
||||
danger,
|
||||
onDescriptionHoverChange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -89,24 +91,44 @@ const ControlHeader: FC<ControlHeaderProps> = ({
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
padding-left: ${theme.sizeUnit}px;
|
||||
transform: translate(100%, -50%);
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
`}
|
||||
>
|
||||
{description && (
|
||||
<span>
|
||||
<>
|
||||
<Tooltip
|
||||
id="description-tooltip"
|
||||
title={description}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<Icons.InfoCircleOutlined
|
||||
css={iconStyles}
|
||||
{/* Same role="button" pattern as the label text: a real <button>
|
||||
is not valid inside FormLabel's <label>. */}
|
||||
<span
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-test={`${name}-description-icon`}
|
||||
aria-label={t('Show info tooltip')}
|
||||
onMouseEnter={() => onDescriptionHoverChange?.(true)}
|
||||
onMouseLeave={() => onDescriptionHoverChange?.(false)}
|
||||
onFocus={() => onDescriptionHoverChange?.(true)}
|
||||
onBlur={() => onDescriptionHoverChange?.(false)}
|
||||
onClick={tooltipOnClick}
|
||||
/>
|
||||
onKeyDown={handleKeyboardActivation(tooltipOnClick)}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
`}
|
||||
>
|
||||
<Icons.InfoCircleOutlined css={iconStyles} />
|
||||
</span>
|
||||
</Tooltip>{' '}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{renderTrigger && (
|
||||
<span>
|
||||
|
||||
+11
-4
@@ -68,6 +68,8 @@ export const TableControls = ({
|
||||
canDownload,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
effectiveRowLimit,
|
||||
limitReachedMessage,
|
||||
onRowLimitChange,
|
||||
onDownloadCSV,
|
||||
onDownloadXLSX,
|
||||
@@ -111,14 +113,19 @@ export const TableControls = ({
|
||||
value={rowLimit}
|
||||
onChange={onRowLimitChange}
|
||||
options={rowLimitOptions ?? []}
|
||||
// Labelled as the applied limit to avoid a second row count next to RowCountLabel.
|
||||
prefix={t('Limit')}
|
||||
css={css`
|
||||
min-width: 110px;
|
||||
min-width: 160px;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
)}
|
||||
<RowCountLabel
|
||||
rowcount={rowcount}
|
||||
limit={effectiveRowLimit ?? rowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{canDownload && onDownloadCSV && onDownloadXLSX && (
|
||||
<DownloadDropdown
|
||||
onDownloadCSV={onDownloadCSV}
|
||||
|
||||
@@ -136,6 +136,12 @@ export const SamplesPane = ({
|
||||
|
||||
const columns = useGridColumns(colnames, coltypes, data);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
// Samples aren't capped by a chart's row_limit, just this pane's own
|
||||
// page-size selector, so RowCountLabel's default "chart" wording is wrong here.
|
||||
const limitReachedMessage = t(
|
||||
'The sample row limit was reached. This %s may contain more rows.',
|
||||
datasetLabelLower(),
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -161,6 +167,7 @@ export const SamplesPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<ErrorAlertWrapper>
|
||||
@@ -197,6 +204,7 @@ export const SamplesPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<GridContainer>
|
||||
|
||||
+4
@@ -56,6 +56,8 @@ export const SingleQueryResultPane = ({
|
||||
columnDisplayNames,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
effectiveRowLimit,
|
||||
limitReachedMessage,
|
||||
onRowLimitChange,
|
||||
onDownloadCSV,
|
||||
onDownloadXLSX,
|
||||
@@ -86,6 +88,8 @@ export const SingleQueryResultPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={rowLimitOptions}
|
||||
effectiveRowLimit={effectiveRowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={onRowLimitChange}
|
||||
onDownloadCSV={onDownloadCSV}
|
||||
onDownloadXLSX={onDownloadXLSX}
|
||||
|
||||
@@ -84,6 +84,17 @@ export const useResultsPane = ({
|
||||
// Never exceed the chart's own row_limit
|
||||
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
|
||||
|
||||
// When this pane's own row-limit selector is stricter than the chart's
|
||||
// row_limit, it - not the chart - is what caps the result, so
|
||||
// RowCountLabel's default "chart" wording would be misleading (the chart's
|
||||
// configured row_limit was never actually reached).
|
||||
const limitReachedMessage =
|
||||
rowLimit < chartRowLimit
|
||||
? t(
|
||||
'The row limit selected for this pane was reached. There may be more matching rows.',
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const cappedFormData = useMemo(
|
||||
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
|
||||
[queryFormData, effectiveRowLimit],
|
||||
@@ -236,6 +247,8 @@ export const useResultsPane = ({
|
||||
columnDisplayNames={columnDisplayNames}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
effectiveRowLimit={effectiveRowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
</StyledDiv>
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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 {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
sleep,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
TableControls,
|
||||
ROW_LIMIT_OPTIONS,
|
||||
} from '../components/DataTableControls';
|
||||
import { TableControlsProps } from '../types';
|
||||
|
||||
const setup = (overrides: Partial<TableControlsProps> = {}) =>
|
||||
render(
|
||||
<TableControls
|
||||
data={[]}
|
||||
columnNames={['name']}
|
||||
columnTypes={[GenericDataType.String]}
|
||||
rowcount={0}
|
||||
onInputChange={jest.fn()}
|
||||
isLoading={false}
|
||||
canDownload
|
||||
rowLimit={100}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={jest.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
test('shows the row count when the result fills the selected row limit', () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByTestId('row-count-label')).toHaveTextContent('100 rows');
|
||||
});
|
||||
|
||||
test('warns that the row limit was reached when the result fills it', async () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not warn when the result is smaller than the selected row limit', async () => {
|
||||
setup({ rowcount: 42, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByTestId('row-count-label')).toHaveTextContent('42 rows');
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
// Wait past antd's 0.1s mouseEnterDelay so a regression that made the
|
||||
// tooltip appear would be caught here instead of racing the delay.
|
||||
await act(() => sleep(150));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("warns when the chart's own row limit truncates below the selected row limit", async () => {
|
||||
setup({ rowcount: 250, rowLimit: 1000, effectiveRowLimit: 250 });
|
||||
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test('labels the row limit selector so it is not read as a second row count', () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByText('Limit')).toBeInTheDocument();
|
||||
});
|
||||
+42
-1
@@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { screen, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
screen,
|
||||
render,
|
||||
waitFor,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneOnDashboard } from '../components';
|
||||
@@ -157,6 +162,42 @@ describe('useResultsPane query data reuse', () => {
|
||||
expect(screen.queryByText('Sci-Fi')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('2 rows')).toBeVisible();
|
||||
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
|
||||
|
||||
userEvent.hover(screen.getByText('2 rows'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test("warns about this pane's own row limit, not the chart's, when the pane's selector is what caps the result", async () => {
|
||||
// chart row_limit (2000) is well above this pane's default 1000-row
|
||||
// selector, so the selector - not the chart - is what truncates here.
|
||||
const props = createResultsPaneOnDashboardProps({
|
||||
sliceId: 208,
|
||||
rowLimit: 2000,
|
||||
queriesResponse: [
|
||||
{
|
||||
colnames: ['genre'],
|
||||
coltypes: [1],
|
||||
data: Array.from({ length: 1500 }, (_, i) => ({
|
||||
genre: `genre-${i}`,
|
||||
})),
|
||||
rowcount: 1500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
|
||||
|
||||
const rowCountLabel = await screen.findByTestId('row-count-label');
|
||||
expect(rowCountLabel).toHaveTextContent('1k rows');
|
||||
|
||||
userEvent.hover(rowCountLabel);
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent(
|
||||
'The row limit selected for this pane was reached',
|
||||
);
|
||||
expect(tooltip).not.toHaveTextContent('for the chart');
|
||||
});
|
||||
|
||||
test('renders an empty (0 rows) result from reused data without an API call', async () => {
|
||||
|
||||
@@ -84,6 +84,12 @@ export interface TableControlsProps extends DrillControlsProps {
|
||||
canDownload: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
// Effective result limit, capped by the chart's row limit.
|
||||
// Defaults to `rowLimit` and controls the "row limit reached" warning.
|
||||
effectiveRowLimit?: number;
|
||||
// Overrides RowCountLabel's default "chart" wording for panes (e.g.
|
||||
// samples) where the limit reached isn't the chart's own row_limit.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -104,5 +110,9 @@ export interface SingleQueryResultPaneProp
|
||||
columnDisplayNames?: Record<string, string>;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
effectiveRowLimit?: number;
|
||||
// Overrides RowCountLabel's default "chart" wording when the pane's own
|
||||
// row-limit selector, not the chart's row_limit, is what capped the result.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChangeEvent, useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
ChangeEvent,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
Input,
|
||||
@@ -56,6 +63,12 @@ export type PropertiesModalProps = {
|
||||
permissionsError?: string;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
/** Optional render prop for injecting extra fields (e.g. folder selector). */
|
||||
renderExtraFields?: (context: {
|
||||
assetId: number;
|
||||
assetType: 'chart';
|
||||
accessorCount: number;
|
||||
}) => { content: ReactNode; saveDisabled?: boolean; saveTooltip?: string };
|
||||
};
|
||||
|
||||
function PropertiesModal({
|
||||
@@ -65,6 +78,7 @@ function PropertiesModal({
|
||||
show,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
renderExtraFields,
|
||||
}: PropertiesModalProps) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// values of form inputs
|
||||
@@ -87,6 +101,25 @@ function PropertiesModal({
|
||||
>(null);
|
||||
const [tags, setTags] = useState<TagType[]>([]);
|
||||
|
||||
const chartId = slice.slice_id;
|
||||
const extraFields = useMemo(
|
||||
() =>
|
||||
chartId
|
||||
? renderExtraFields?.({
|
||||
assetId: chartId,
|
||||
assetType: 'chart',
|
||||
accessorCount:
|
||||
(selectedEditors?.length ?? 0) + (selectedViewers?.length ?? 0),
|
||||
})
|
||||
: undefined,
|
||||
[
|
||||
chartId,
|
||||
renderExtraFields,
|
||||
selectedEditors?.length,
|
||||
selectedViewers?.length,
|
||||
],
|
||||
);
|
||||
|
||||
// Validation setup
|
||||
const modalSections = useMemo(
|
||||
() => [
|
||||
@@ -281,14 +314,20 @@ function PropertiesModal({
|
||||
title={t('Chart properties')}
|
||||
isEditMode
|
||||
saveDisabled={
|
||||
submitting || !name || slice.is_managed_externally || hasErrors
|
||||
submitting ||
|
||||
!name ||
|
||||
slice.is_managed_externally ||
|
||||
hasErrors ||
|
||||
extraFields?.saveDisabled
|
||||
}
|
||||
errorTooltip={
|
||||
slice.is_managed_externally
|
||||
? t(
|
||||
"This chart is managed externally, and can't be edited in Superset",
|
||||
)
|
||||
: errorTooltip
|
||||
extraFields?.saveDisabled && extraFields?.saveTooltip
|
||||
? extraFields.saveTooltip
|
||||
: slice.is_managed_externally
|
||||
? t(
|
||||
"This chart is managed externally, and can't be edited in Superset",
|
||||
)
|
||||
: errorTooltip
|
||||
}
|
||||
wrapProps={{ 'data-test': 'properties-edit-modal' }}
|
||||
>
|
||||
@@ -395,6 +434,7 @@ function PropertiesModal({
|
||||
/>
|
||||
</ModalFormField>
|
||||
)}
|
||||
{extraFields?.content}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
+18
-2
@@ -147,6 +147,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
onOpenPopover = noOp,
|
||||
onClosePopover = noOp,
|
||||
isOverflowingFilterBar = false,
|
||||
hovered: isControlHovered = false,
|
||||
} = props;
|
||||
const defaultTimeFilter = useDefaultTimeFilter();
|
||||
|
||||
@@ -161,9 +162,16 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
|
||||
const [evalResponse, setEvalResponse] = useState<string>(value);
|
||||
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(t(value));
|
||||
const [isDescriptionHovered, setIsDescriptionHovered] = useState(false);
|
||||
const theme = useTheme();
|
||||
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlHovered) {
|
||||
setIsDescriptionHovered(false);
|
||||
}
|
||||
}, [isControlHovered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === NO_TIME_RANGE) {
|
||||
setActualTimeRange(NO_TIME_RANGE);
|
||||
@@ -368,7 +376,12 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
}
|
||||
overlayClassName="time-range-popover"
|
||||
>
|
||||
<Tooltip placement="top" title={tooltipTitle}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={isDescriptionHovered ? null : tooltipTitle}
|
||||
mouseLeaveDelay={0}
|
||||
overlayStyle={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Wrap in a span so the Popover gets a stable DOM ref target;
|
||||
DateLabel forwards its ref to an inner span used for measuring
|
||||
text truncation, which would otherwise become the popover's
|
||||
@@ -390,7 +403,10 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlHeader {...props} />
|
||||
<ControlHeader
|
||||
{...props}
|
||||
onDescriptionHoverChange={setIsDescriptionHovered}
|
||||
/>
|
||||
{popoverContent}
|
||||
</>
|
||||
);
|
||||
|
||||
+86
-4
@@ -18,16 +18,35 @@
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
|
||||
import { NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import { NO_TIME_RANGE, fetchTimeRange } from '@superset-ui/core';
|
||||
import DateFilterLabel from '..';
|
||||
import { DateFilterControlProps } from '../types';
|
||||
import { DateFilterTestKey } from '../utils';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
fetchTimeRange: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedFetchTimeRange = fetchTimeRange as jest.MockedFunction<
|
||||
typeof fetchTimeRange
|
||||
>;
|
||||
|
||||
const FIELD_TOOLTIP = '2024-01-01 ≤ col < 2024-01-08';
|
||||
const DESCRIPTION_TOOLTIP =
|
||||
'This control filters the whole chart based on the selected time range.';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
const defaultProps = {
|
||||
onChange: jest.fn(),
|
||||
@@ -35,6 +54,11 @@ const defaultProps = {
|
||||
onOpenPopover: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetchTimeRange.mockReset();
|
||||
mockedFetchTimeRange.mockResolvedValue({ value: FIELD_TOOLTIP });
|
||||
});
|
||||
|
||||
function setup(
|
||||
props: Omit<DateFilterControlProps, 'name'> = defaultProps,
|
||||
store: any = mockStore({}),
|
||||
@@ -136,3 +160,61 @@ test('DateFilter should properly handle isOverflowingFilterBar prop changes', ()
|
||||
expect(popoverAfterRerender?.parentElement).toBe(trigger.parentElement);
|
||||
expect(popoverAfterRerender?.parentElement).not.toBe(document.body);
|
||||
});
|
||||
|
||||
test('hovering the description icon does not show the date range tooltip', async () => {
|
||||
const tooltipOnClick = jest.fn();
|
||||
render(
|
||||
setup({
|
||||
...defaultProps,
|
||||
value: 'Last week',
|
||||
label: 'Date Range',
|
||||
description: DESCRIPTION_TOOLTIP,
|
||||
hovered: true,
|
||||
tooltipOnClick,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Last week')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.hover(screen.getByText('Last week'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(FIELD_TOOLTIP);
|
||||
|
||||
const descriptionIcon = screen.getByRole('button', {
|
||||
name: 'Show info tooltip',
|
||||
});
|
||||
fireEvent.focus(descriptionIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(DESCRIPTION_TOOLTIP);
|
||||
expect(screen.getByRole('tooltip')).not.toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
});
|
||||
|
||||
fireEvent.blur(descriptionIcon);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
});
|
||||
|
||||
await userEvent.unhover(screen.getByText('Last week'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.hover(descriptionIcon);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent(DESCRIPTION_TOOLTIP);
|
||||
expect(tooltip).not.toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
|
||||
await userEvent.unhover(descriptionIcon);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(descriptionIcon, { key: 'Enter' });
|
||||
expect(tooltipOnClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type SelectOptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -113,4 +115,8 @@ export interface DateFilterControlProps {
|
||||
onOpenPopover?: () => void;
|
||||
onClosePopover?: () => void;
|
||||
isOverflowingFilterBar?: boolean;
|
||||
hovered?: boolean;
|
||||
description?: ReactNode;
|
||||
label?: ReactNode;
|
||||
tooltipOnClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
AppSection,
|
||||
Behavior,
|
||||
ChartProps,
|
||||
type DataMask,
|
||||
type FilterState,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
@@ -1393,6 +1395,153 @@ test('preserves dependent filter value restored from URL when it exists in data'
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps a dependent filter empty after the user clears it', async () => {
|
||||
// Regression: a dependent filter with "Select first filter value by default"
|
||||
// used to re-apply the first option as soon as the cleared value round-tripped
|
||||
// through the filter bar, making it impossible to clear.
|
||||
jest.useRealTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
const testProps = {
|
||||
...selectMultipleProps,
|
||||
formData: {
|
||||
...selectMultipleProps.formData,
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: true,
|
||||
// Non-empty extraFormData is what marks this filter as dependent
|
||||
extraFormData: {
|
||||
filters: [{ col: 'region', op: 'IN', val: ['North America'] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// The filter bar feeds every dispatched dataMask back into the plugin as the
|
||||
// controlled `filterState` prop; the harness reproduces that round-trip.
|
||||
const ControlledSelectFilter = () => {
|
||||
const [filterState, setFilterState] = useState<FilterState>({
|
||||
value: ['boy'],
|
||||
});
|
||||
const handleDataMask = useCallback((dataMask: DataMask) => {
|
||||
setDataMaskMock(dataMask);
|
||||
setFilterState(prev => ({ ...prev, ...dataMask.filterState }));
|
||||
}, []);
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<SelectFilterPlugin
|
||||
// @ts-expect-error
|
||||
{...transformProps({ ...testProps, filterState })}
|
||||
setDataMask={handleDataMask}
|
||||
showOverflow={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<ControlledSelectFilter />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'test-filter': {
|
||||
name: 'Test Filter',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {},
|
||||
filterState: { value: ['boy'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
userEvent.click(
|
||||
screen.getByRole('img', {
|
||||
name: /close-circle/i,
|
||||
hidden: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setDataMaskMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
extraFormData: {},
|
||||
filterState: expect.objectContaining({ value: null }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Let the re-validation effects settle: the value must not come back
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(setDataMaskMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filterState: expect.objectContaining({ value: null }),
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByTitle('boy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps a dependent filter empty when it mounts with a cleared value', async () => {
|
||||
// Regression: after a reload the cleared state comes back as `value: null` on
|
||||
// a fresh component, so the in-memory "user cleared this" ref is gone. The
|
||||
// first item must still not be re-applied.
|
||||
const setDataMaskMock = jest.fn();
|
||||
const testProps = {
|
||||
...selectMultipleProps,
|
||||
formData: {
|
||||
...selectMultipleProps.formData,
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: true,
|
||||
extraFormData: {
|
||||
filters: [{ col: 'region', op: 'IN', val: ['North America'] }],
|
||||
},
|
||||
},
|
||||
filterState: { value: null },
|
||||
};
|
||||
|
||||
render(
|
||||
// @ts-expect-error
|
||||
<SelectFilterPlugin
|
||||
// @ts-expect-error
|
||||
{...transformProps(testProps)}
|
||||
setDataMask={setDataMaskMock}
|
||||
showOverflow={false}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'test-filter': {
|
||||
name: 'Test Filter',
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {},
|
||||
filterState: { value: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Let the re-validation effect run before asserting it did nothing
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
expect(setDataMaskMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterState: expect.objectContaining({ value: ['boy'] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('resets dependent filter to first item when value does not exist in data', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
const testProps = {
|
||||
|
||||
@@ -157,7 +157,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
const [col] = groupby;
|
||||
const [initialColtypeMap] = useState(coltypeMap);
|
||||
const [search, setSearch] = useState('');
|
||||
const prevDataRef = useRef(data);
|
||||
const userClearedRef = useRef(false);
|
||||
const [dataMask, dispatchDataMask] = useImmerReducer(reducer, {
|
||||
extraFormData: {},
|
||||
@@ -430,26 +429,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
clearAllTrigger,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevDataRef.current;
|
||||
const curr = data;
|
||||
|
||||
const hasDataChanged =
|
||||
prev?.length !== curr?.length ||
|
||||
prev?.some((row, i) => {
|
||||
const prevVal = row[col];
|
||||
const currVal = curr[i][col];
|
||||
return typeof prevVal === 'bigint' || typeof currVal === 'bigint'
|
||||
? prevVal?.toString() !== currVal?.toString()
|
||||
: prevVal !== currVal;
|
||||
});
|
||||
|
||||
// If data actually changed (e.g., due to parent filter), reset flag
|
||||
if (hasDataChanged) {
|
||||
prevDataRef.current = data;
|
||||
}
|
||||
}, [data, col]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
filterState.value?.every((value?: any) =>
|
||||
@@ -462,13 +441,17 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
? (groupby.map(col => data[0][col]) as string[])
|
||||
: null;
|
||||
|
||||
// Skip default value update when clearAllTrigger is active
|
||||
// Skip default value update when clearAllTrigger is active.
|
||||
// `null` is a persisted "user cleared this" state, as opposed to
|
||||
// `undefined` for "never set", so it must not be re-defaulted either —
|
||||
// `userClearedRef` alone would not survive a reload.
|
||||
if (
|
||||
!clearAllTrigger &&
|
||||
defaultToFirstItem &&
|
||||
!userClearedRef.current &&
|
||||
Object.keys(formData?.extraFormData || {}).length &&
|
||||
filterState.value !== undefined &&
|
||||
filterState.value !== null &&
|
||||
firstItem !== null &&
|
||||
filterState.value !== firstItem
|
||||
) {
|
||||
|
||||
@@ -585,8 +585,17 @@ class ChartDataRestApi(ChartRestApi):
|
||||
query["timing"] = query_result.timing.as_public_dict()
|
||||
|
||||
if security_manager.is_guest_user():
|
||||
# Guests may see the generated SQL only when the role attached to
|
||||
# their guest token has been granted "can view query on Dashboard",
|
||||
# mirroring the permission the frontend uses to expose the
|
||||
# "View query" action. Stacktraces and driver errors stay redacted
|
||||
# regardless, as those leak details of the deployment itself.
|
||||
can_view_query = security_manager.can_access(
|
||||
"can_view_query", "Dashboard"
|
||||
)
|
||||
for query in queries:
|
||||
query.pop("query", None)
|
||||
if not can_view_query:
|
||||
query.pop("query", None)
|
||||
query.pop("stacktrace", None)
|
||||
if query.get("error"):
|
||||
query["error"] = sanitize_error_message(query["error"])
|
||||
|
||||
@@ -91,8 +91,10 @@ class ExportChartsCommand(ExportModelsCommand):
|
||||
def enable_tag_export(cls) -> None:
|
||||
cls._include_tags = True
|
||||
|
||||
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
yield from super().run()
|
||||
def run(
|
||||
self, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
yield from super().run(seen=seen)
|
||||
|
||||
# Tags are exported once for all requested charts (rather than per
|
||||
# chart in `_export`) so a multi-chart export doesn't lose tags to
|
||||
@@ -108,12 +110,17 @@ class ExportChartsCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: Slice, export_related: bool = True
|
||||
model: Slice, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportChartsCommand._file_name(model),
|
||||
lambda: ExportChartsCommand._file_content(model),
|
||||
)
|
||||
|
||||
if model.table and export_related:
|
||||
yield from ExportDatasetsCommand([model.table.id]).run()
|
||||
# Pass the shared seen set to the dataset export command
|
||||
yield from ExportDatasetsCommand([model.table.id]).run(seen=seen)
|
||||
|
||||
@@ -383,8 +383,12 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
@staticmethod
|
||||
# ruff: noqa: C901
|
||||
def _export(
|
||||
model: Dashboard, export_related: bool = True
|
||||
model: Dashboard, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportDashboardsCommand._file_name(model),
|
||||
lambda: ExportDashboardsCommand._file_content(model),
|
||||
@@ -395,8 +399,11 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
dashboard_ids = model.id
|
||||
command = ExportChartsCommand(chart_ids)
|
||||
command.disable_tag_export()
|
||||
yield from command.run()
|
||||
command.enable_tag_export()
|
||||
try:
|
||||
# Pass the shared seen set to the chart export command
|
||||
yield from command.run(seen=seen)
|
||||
finally:
|
||||
command.enable_tag_export()
|
||||
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||
yield from ExportTagsCommand(
|
||||
dashboard_ids=dashboard_ids, chart_ids=chart_ids
|
||||
@@ -406,7 +413,8 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
if model.theme:
|
||||
from superset.commands.theme.export import ExportThemesCommand
|
||||
|
||||
yield from ExportThemesCommand([model.theme.id]).run()
|
||||
# Pass the shared seen set to the theme export command
|
||||
yield from ExportThemesCommand([model.theme.id]).run(seen=seen)
|
||||
|
||||
payload = model.export_to_dict(
|
||||
recursive=False,
|
||||
@@ -435,7 +443,10 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
if dataset_id is not None:
|
||||
dataset = DatasetDAO.find_by_id(dataset_id)
|
||||
if dataset:
|
||||
yield from ExportDatasetsCommand([dataset_id]).run()
|
||||
# Pass the shared seen set to the dataset export command
|
||||
yield from ExportDatasetsCommand([dataset_id]).run(
|
||||
seen=seen
|
||||
)
|
||||
|
||||
# Export datasets referenced by display controls
|
||||
for customization in (
|
||||
@@ -446,4 +457,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
if dataset_id is not None:
|
||||
dataset = DatasetDAO.find_by_id(dataset_id)
|
||||
if dataset:
|
||||
yield from ExportDatasetsCommand([dataset_id]).run()
|
||||
# Pass the shared seen set to the dataset export command
|
||||
yield from ExportDatasetsCommand([dataset_id]).run(
|
||||
seen=seen
|
||||
)
|
||||
|
||||
@@ -113,8 +113,12 @@ class ExportDatabasesCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: Database, export_related: bool = True
|
||||
model: Database, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportDatabasesCommand._file_name(model),
|
||||
lambda: ExportDatabasesCommand._file_content(model),
|
||||
|
||||
@@ -219,6 +219,8 @@ class UploadCommand(BaseCommand):
|
||||
database_id=self._model_id,
|
||||
editors=editors,
|
||||
schema=self._schema,
|
||||
# Ensure catalog is set
|
||||
catalog=self._model.get_default_catalog(),
|
||||
)
|
||||
db.session.add(sqla_table)
|
||||
|
||||
|
||||
@@ -33,7 +33,18 @@ from superset.commands.dataset.exceptions import (
|
||||
)
|
||||
from superset.commands.utils import populate_subjects
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.db_engine_specs.exceptions import (
|
||||
SupersetDBAPIConnectionError,
|
||||
SupersetDBAPIDatabaseError,
|
||||
SupersetDBAPIOperationalError,
|
||||
)
|
||||
from superset.exceptions import (
|
||||
OAuth2RedirectError,
|
||||
SupersetException,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
SupersetTimeoutException,
|
||||
)
|
||||
from superset.extensions import security_manager
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
@@ -50,7 +61,38 @@ class CreateDatasetCommand(CreateMixin, BaseCommand):
|
||||
self.validate()
|
||||
|
||||
dataset = DatasetDAO.create(attributes=self._properties)
|
||||
dataset.fetch_metadata()
|
||||
try:
|
||||
dataset.fetch_metadata()
|
||||
except OAuth2RedirectError:
|
||||
# Must reach the caller unchanged to start the OAuth2 dance.
|
||||
raise
|
||||
except (
|
||||
SupersetTimeoutException,
|
||||
SupersetDBAPIConnectionError,
|
||||
SupersetDBAPIOperationalError,
|
||||
SupersetDBAPIDatabaseError,
|
||||
):
|
||||
# Infra-level failures (unreachable database, query timeout), not
|
||||
# bad user input: let them propagate with their own status
|
||||
# instead of being coerced into a 422 "invalid table" error.
|
||||
raise
|
||||
except SupersetException as ex:
|
||||
# Not a SQLAlchemyError, so ``on_error`` re-raises it untouched and
|
||||
# it escapes to FAB's ``@safe`` as an opaque 500 "Fatal error".
|
||||
# Deliberately covers the 403 ``SupersetSecurityException`` raised
|
||||
# for mutation/multi-statement SQL too: ``validate()`` already
|
||||
# reports that class of rejection as a 422 on ``sql`` via
|
||||
# ``DatasetDataAccessIsNotAllowed``.
|
||||
raise DatasetInvalidError(
|
||||
exceptions=[
|
||||
ValidationError(
|
||||
# ``lazy_gettext`` messages aren't ``str``, so
|
||||
# marshmallow won't wrap them into a list on its own.
|
||||
[str(ex.message)],
|
||||
field_name="sql" if self._properties.get("sql") else "table",
|
||||
)
|
||||
]
|
||||
) from ex
|
||||
return dataset
|
||||
|
||||
def validate(self) -> None: # noqa: C901
|
||||
|
||||
@@ -89,8 +89,12 @@ class ExportDatasetsCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: SqlaTable, export_related: bool = True
|
||||
model: SqlaTable, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportDatasetsCommand._file_name(model),
|
||||
lambda: ExportDatasetsCommand._file_content(model),
|
||||
@@ -103,32 +107,41 @@ class ExportDatasetsCommand(ExportModelsCommand):
|
||||
)
|
||||
file_path = f"databases/{db_file_name}.yaml"
|
||||
|
||||
payload = model.database.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=True,
|
||||
)
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
if payload.get("extra"):
|
||||
try:
|
||||
payload["extra"] = json.loads(payload["extra"])
|
||||
except json.JSONDecodeError:
|
||||
logger.info("Unable to decode `extra` field: %s", payload["extra"])
|
||||
|
||||
if ssh_tunnel := model.database.ssh_tunnel:
|
||||
ssh_tunnel_payload = ssh_tunnel.export_to_dict(
|
||||
# Only yield the database file if not already seen. This is
|
||||
# critical to fix the issue where databases were being
|
||||
# duplicated and potentially overwritten when charts from
|
||||
# different databases were exported.
|
||||
if file_path not in seen:
|
||||
payload = model.database.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=False,
|
||||
export_uuids=True,
|
||||
)
|
||||
payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload)
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
if payload.get("extra"):
|
||||
try:
|
||||
payload["extra"] = json.loads(payload["extra"])
|
||||
except json.JSONDecodeError:
|
||||
logger.info(
|
||||
"Unable to decode `extra` field: %s", payload["extra"]
|
||||
)
|
||||
|
||||
payload["version"] = EXPORT_VERSION
|
||||
if ssh_tunnel := model.database.ssh_tunnel:
|
||||
ssh_tunnel_payload = ssh_tunnel.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=False,
|
||||
)
|
||||
payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload)
|
||||
|
||||
yield (
|
||||
file_path,
|
||||
lambda: yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
|
||||
)
|
||||
payload["version"] = EXPORT_VERSION
|
||||
|
||||
yield (
|
||||
file_path,
|
||||
lambda: yaml.safe_dump(
|
||||
payload, sort_keys=False, allow_unicode=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -384,7 +384,7 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
)
|
||||
exceptions.append(
|
||||
ValidationError(
|
||||
message,
|
||||
[message],
|
||||
field_name=f"{label}.{idx}.expression",
|
||||
)
|
||||
)
|
||||
@@ -412,7 +412,7 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
)
|
||||
exceptions.append(
|
||||
ValidationError(
|
||||
message,
|
||||
[message],
|
||||
field_name="fetch_values_predicate",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -47,27 +47,45 @@ class ExportModelsCommand(BaseCommand):
|
||||
|
||||
@staticmethod
|
||||
def _file_content(model: Model) -> str:
|
||||
raise NotImplementedError("Subclasses MUST implement _export")
|
||||
raise NotImplementedError("Subclasses MUST implement _file_content")
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: Model, export_related: bool = True
|
||||
model: Model, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
raise NotImplementedError("Subclasses MUST implement _export")
|
||||
|
||||
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
def run(
|
||||
self, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
self.validate()
|
||||
|
||||
metadata = {
|
||||
"version": EXPORT_VERSION,
|
||||
"type": self.dao.model_cls.__name__, # type: ignore
|
||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
yield METADATA_FILE_NAME, lambda: yaml.safe_dump(metadata, sort_keys=False)
|
||||
# Use provided seen set or create new one
|
||||
if seen is None:
|
||||
seen = set()
|
||||
should_add_metadata = True
|
||||
else:
|
||||
# If seen set is provided, we're being called from another command
|
||||
should_add_metadata = False
|
||||
|
||||
# Only add metadata if this is the root command
|
||||
if should_add_metadata:
|
||||
metadata = {
|
||||
"version": EXPORT_VERSION,
|
||||
"type": self.dao.model_cls.__name__, # type: ignore
|
||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
if METADATA_FILE_NAME not in seen:
|
||||
yield (
|
||||
METADATA_FILE_NAME,
|
||||
lambda: yaml.safe_dump(metadata, sort_keys=False),
|
||||
)
|
||||
seen.add(METADATA_FILE_NAME)
|
||||
|
||||
seen = {METADATA_FILE_NAME}
|
||||
for model in self._models:
|
||||
for file_name, file_content in self._export(model, self.export_related):
|
||||
for file_name, file_content in self._export(
|
||||
model, self.export_related, seen
|
||||
):
|
||||
if file_name not in seen:
|
||||
yield file_name, file_content
|
||||
seen.add(file_name)
|
||||
|
||||
@@ -67,8 +67,12 @@ class ExportSavedQueriesCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: SavedQuery, export_related: bool = True
|
||||
model: SavedQuery, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportSavedQueriesCommand._file_name(model),
|
||||
lambda: ExportSavedQueriesCommand._file_content(model),
|
||||
@@ -79,21 +83,25 @@ class ExportSavedQueriesCommand(ExportModelsCommand):
|
||||
database_slug = secure_filename(model.database.database_name)
|
||||
file_name = f"databases/{database_slug}.yaml"
|
||||
|
||||
payload = model.database.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=True,
|
||||
)
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
if "extra" in payload:
|
||||
try:
|
||||
payload["extra"] = json.loads(payload["extra"])
|
||||
except json.JSONDecodeError:
|
||||
logger.info("Unable to decode `extra` field: %s", payload["extra"])
|
||||
# Only yield if not already seen (similar to dataset export)
|
||||
if file_name not in seen:
|
||||
payload = model.database.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=True,
|
||||
)
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
if "extra" in payload:
|
||||
try:
|
||||
payload["extra"] = json.loads(payload["extra"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.info(
|
||||
"Unable to decode `extra` field: %s", payload["extra"]
|
||||
)
|
||||
|
||||
payload["version"] = EXPORT_VERSION
|
||||
payload["version"] = EXPORT_VERSION
|
||||
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False)
|
||||
yield file_name, lambda: file_content
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False)
|
||||
yield file_name, lambda: file_content
|
||||
|
||||
@@ -46,7 +46,9 @@ class ExportTagsCommand(ExportModelsCommand):
|
||||
self.dashboard_ids = dashboard_ids
|
||||
self.chart_ids = chart_ids
|
||||
|
||||
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
def run(
|
||||
self, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||
return
|
||||
|
||||
|
||||
@@ -67,8 +67,12 @@ class ExportThemesCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: Theme, export_related: bool = True
|
||||
model: Theme, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided (for consistency)
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportThemesCommand._file_name(model),
|
||||
lambda: ExportThemesCommand._file_content(model),
|
||||
|
||||
@@ -34,6 +34,7 @@ from superset import db
|
||||
from superset.constants import LRU_CACHE_MAX_SIZE
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
SupersetErrorException,
|
||||
SupersetGenericDBErrorException,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
@@ -125,12 +126,14 @@ def get_virtual_table_metadata(dataset: SqlaTable) -> list[ResultSetColumnType]:
|
||||
# rest (sandbox violations, malformed template syntax, encoding
|
||||
# errors) indicate a real problem with the template that must
|
||||
# surface. See #38012.
|
||||
# str(ex) stringifies the raw SupersetError list (enum reprs and all).
|
||||
error_message = "; ".join(err.message for err in ex.errors)
|
||||
if isinstance(ex.__cause__, UndefinedError):
|
||||
raise SupersetVirtualTableParseException(
|
||||
message=_("Template processing error: %(error)s", error=str(ex)),
|
||||
message=_("Template processing error: %(error)s", error=error_message),
|
||||
) from ex
|
||||
raise SupersetGenericDBErrorException(
|
||||
message=_("Template processing error: %(error)s", error=str(ex)),
|
||||
message=_("Template processing error: %(error)s", error=error_message),
|
||||
) from ex
|
||||
try:
|
||||
parsed_script = SQLScript(sql, engine=db_engine_spec.engine)
|
||||
@@ -209,6 +212,11 @@ def get_columns_description(
|
||||
result, cursor.description, db_engine_spec
|
||||
)
|
||||
return result_set.columns
|
||||
except SupersetErrorException:
|
||||
# Preserve exceptions that already carry a specific SupersetError
|
||||
# (e.g. OAuth2RedirectError) so callers can act on them instead of
|
||||
# seeing an opaque generic DB error.
|
||||
raise
|
||||
except Exception as ex:
|
||||
raise SupersetGenericDBErrorException(message=str(ex)) from ex
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ from superset.datasets.schemas import (
|
||||
openapi_spec_methods_override,
|
||||
)
|
||||
from superset.exceptions import (
|
||||
OAuth2RedirectError,
|
||||
SupersetSyntaxErrorException,
|
||||
SupersetTemplateException,
|
||||
)
|
||||
@@ -440,7 +441,6 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
|
||||
@expose("/", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
|
||||
@@ -495,6 +495,12 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
data=new_model.data,
|
||||
uuid=new_model.uuid,
|
||||
)
|
||||
except OAuth2RedirectError:
|
||||
# Must reach the client unchanged to start the OAuth2 dance;
|
||||
# ``@safe`` isn't used on this endpoint since it would otherwise
|
||||
# swallow this into an opaque 500 that drops the ``url``/``tab_id``
|
||||
# extras the frontend needs.
|
||||
raise
|
||||
except DatasetSoftDeletedTwinExistsError as ex:
|
||||
return self.response_422(message=str(ex))
|
||||
except DatasetInvalidError as ex:
|
||||
@@ -507,6 +513,14 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# ``@safe`` isn't used on this endpoint (it would swallow the
|
||||
# ``OAuth2RedirectError`` re-raised above into an opaque 500), so
|
||||
# replicate its behavior here for any other unexpected exception:
|
||||
# log the full error server-side, but don't echo internal details
|
||||
# (ORM/driver error text, connection info) back to the caller.
|
||||
logger.exception("Unexpected error in DatasetRestApi.post")
|
||||
return self.response_500(message="Fatal error")
|
||||
|
||||
@expose("/<pk>", methods=("PUT",))
|
||||
@protect()
|
||||
|
||||
@@ -41,7 +41,6 @@ import sqlalchemy.dialects
|
||||
from flask import current_app as app
|
||||
from sqlalchemy.engine.default import DefaultDialect
|
||||
from sqlalchemy.exc import NoSuchModuleError
|
||||
from sqlalchemy.sql import compiler as sqla_compiler
|
||||
|
||||
from superset import feature_flag_manager
|
||||
from superset.db_engine_specs.base import BaseEngineSpec
|
||||
@@ -160,16 +159,6 @@ def get_available_engine_specs() -> dict[type[BaseEngineSpec], set[str]]: # noq
|
||||
continue
|
||||
|
||||
# installed 3rd-party dialects
|
||||
#
|
||||
# `ep.load()` runs arbitrary module-level code in the third-party package.
|
||||
# Some dialects (e.g. sqlalchemy-monetdb) mutate SQLAlchemy's shared,
|
||||
# process-global `compiler.OPERATORS` mapping in place on import instead
|
||||
# of subclassing it, which would otherwise silently change SQL rendering
|
||||
# (e.g. `!=` -> `<>`) for every dialect for the rest of the process, not
|
||||
# just the misbehaving one. Snapshot/restore around each load so a
|
||||
# buggy connector can't leak global compiler state into unrelated
|
||||
# dialects just because it was enumerated here.
|
||||
operators_snapshot = dict(sqla_compiler.OPERATORS)
|
||||
for ep in entry_points(group="sqlalchemy.dialects"):
|
||||
try:
|
||||
dialect = ep.load()
|
||||
@@ -204,10 +193,6 @@ def get_available_engine_specs() -> dict[type[BaseEngineSpec], set[str]]: # noq
|
||||
if isinstance(driver, bytes):
|
||||
driver = driver.decode()
|
||||
drivers[backend].add(driver)
|
||||
finally:
|
||||
if sqla_compiler.OPERATORS != operators_snapshot:
|
||||
sqla_compiler.OPERATORS.clear()
|
||||
sqla_compiler.OPERATORS.update(operators_snapshot)
|
||||
|
||||
dbs_denylist = app.config["DBS_AVAILABLE_DENYLIST"]
|
||||
if not feature_flag_manager.is_feature_enabled("ENABLE_SUPERSET_META_DB"):
|
||||
|
||||
@@ -44,10 +44,7 @@ class CockroachDbEngineSpec(PostgresEngineSpec):
|
||||
DatabaseCategory.TRADITIONAL_RDBMS,
|
||||
DatabaseCategory.OPEN_SOURCE,
|
||||
],
|
||||
# sqlalchemy-cockroachdb declares no DBAPI dependency of its own (see
|
||||
# the comment on the `cockroachdb` extra in pyproject.toml), so a
|
||||
# plain `cockroachdb://` URL also needs psycopg2 installed to connect.
|
||||
"pypi_packages": ["sqlalchemy-cockroachdb", "psycopg2-binary"],
|
||||
"pypi_packages": ["sqlalchemy-cockroachdb", "psycopg2"],
|
||||
"connection_string": "cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable",
|
||||
"default_port": 26257,
|
||||
"docs_url": "https://github.com/cockroachdb/sqlalchemy-cockroachdb",
|
||||
|
||||
@@ -220,6 +220,8 @@ async def create_virtual_dataset( # noqa: C901
|
||||
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
|
||||
)
|
||||
except SupersetGenericDBErrorException as exc:
|
||||
# Defensive backstop for direct raises (see
|
||||
# test_create_virtual_dataset_sql_error_is_actionable).
|
||||
logger.warning("Virtual dataset SQL validation failed", exc_info=True)
|
||||
await ctx.warning(f"Virtual dataset SQL failed validation: {exc}")
|
||||
return CreateVirtualDatasetResponse(
|
||||
|
||||
+11
-3
@@ -162,13 +162,21 @@ def memoized_func(key: str, cache: Cache = cache_manager.cache) -> Callable[...,
|
||||
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
|
||||
should_cache = kwargs.pop("cache", True)
|
||||
force = kwargs.pop("force", False)
|
||||
cache_timeout = kwargs.pop(
|
||||
"cache_timeout", app.config["CACHE_DEFAULT_TIMEOUT"]
|
||||
)
|
||||
# always popped, even when caching is skipped, so it is never forwarded
|
||||
# to the decorated function as an unexpected keyword argument.
|
||||
cache_timeout = kwargs.pop("cache_timeout", None)
|
||||
|
||||
if not should_cache:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# callers may explicitly pass ``cache_timeout=None`` (eg, when a database
|
||||
# has no custom metadata cache timeout configured), which should fall back
|
||||
# to the default timeout rather than be forwarded to the cache backend.
|
||||
# the config lookup happens here so the uncached path stays independent
|
||||
# of the Flask app config.
|
||||
if cache_timeout is None:
|
||||
cache_timeout = app.config["CACHE_DEFAULT_TIMEOUT"]
|
||||
|
||||
# format the key using args/kwargs passed to the decorated function
|
||||
signature = inspect.signature(f)
|
||||
bound_args = signature.bind(*args, **kwargs)
|
||||
|
||||
@@ -34,6 +34,7 @@ import pytest
|
||||
from flask import g, Response
|
||||
from flask.ctx import AppContext
|
||||
|
||||
from superset import security_manager
|
||||
from superset.charts.data.api import ChartDataRestApi
|
||||
from superset.commands.chart.data.get_data_command import ChartDataCommand
|
||||
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
|
||||
@@ -98,6 +99,25 @@ INCOMPATIBLE_ADHOC_COLUMN_FIXTURE: AdhocColumn = {
|
||||
}
|
||||
|
||||
|
||||
def _override_view_query_permission(granted: bool) -> Any:
|
||||
"""
|
||||
Answer ("can_view_query", "Dashboard") with ``granted`` and let every other
|
||||
permission check fall through to the real security manager, so the rest of
|
||||
the request keeps its normal access rules.
|
||||
"""
|
||||
real_can_access = security_manager.can_access
|
||||
|
||||
def can_access(permission_name: str, view_name: str) -> bool:
|
||||
if (permission_name, view_name) == ("can_view_query", "Dashboard"):
|
||||
return granted
|
||||
return real_can_access(permission_name, view_name)
|
||||
|
||||
return mock.patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
side_effect=can_access,
|
||||
)
|
||||
|
||||
|
||||
def _query_timing() -> QueryTiming:
|
||||
return QueryTiming(
|
||||
query_planning_ns=0,
|
||||
@@ -1572,19 +1592,40 @@ class TestGetChartDataApi(BaseTestChartDataApi):
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_chart_data_as_guest_user(self, is_guest_user, has_guest_access):
|
||||
"""
|
||||
Chart data API: Test response does not inlcude the SQL query for embedded
|
||||
users.
|
||||
Chart data API: Test response does not include the SQL query for embedded
|
||||
users whose role lacks "can view query on Dashboard".
|
||||
"""
|
||||
g.user.rls = []
|
||||
is_guest_user.return_value = True
|
||||
has_guest_access.return_value = True
|
||||
|
||||
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
|
||||
with _override_view_query_permission(granted=False):
|
||||
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
excluded_key = "query"
|
||||
assert all([excluded_key not in query for query in result]) # noqa: C419
|
||||
|
||||
@mock.patch("superset.security.manager.SupersetSecurityManager.has_guest_access")
|
||||
@mock.patch("superset.security.manager.SupersetSecurityManager.is_guest_user")
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_chart_data_as_guest_user_allowed_to_view_query(
|
||||
self, is_guest_user, has_guest_access
|
||||
):
|
||||
"""
|
||||
Chart data API: Test response includes the SQL query for embedded users
|
||||
whose role carries "can view query on Dashboard".
|
||||
"""
|
||||
g.user.rls = []
|
||||
is_guest_user.return_value = True
|
||||
has_guest_access.return_value = True
|
||||
|
||||
with _override_view_query_permission(granted=True):
|
||||
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
assert all("query" in query for query in result)
|
||||
|
||||
def test_chart_data_table_chart_with_time_grain_filter(self):
|
||||
"""
|
||||
Chart data API: Test that a table chart that's not using a temporal column can
|
||||
|
||||
@@ -533,6 +533,110 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
{"dashboard_title": "World Bank's Data"},
|
||||
)
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
@patch("superset.security.manager.g")
|
||||
@patch("superset.views.base.g")
|
||||
def test_export_dashboard_cross_database_charts(self, mock_g1, mock_g2):
|
||||
"""
|
||||
Test that dashboards with charts from multiple databases export correctly.
|
||||
This reproduces issue #37113 where charts from different databases were missing.
|
||||
"""
|
||||
mock_g1.user = security_manager.find_user("admin")
|
||||
mock_g2.user = security_manager.find_user("admin")
|
||||
|
||||
# Create a second database for testing
|
||||
second_db = Database(database_name="test_db_2", sqlalchemy_uri="sqlite://")
|
||||
db.session.add(second_db)
|
||||
|
||||
# Create a dataset in the second database
|
||||
second_dataset = SqlaTable(
|
||||
table_name="second_dataset",
|
||||
database=second_db,
|
||||
database_id=second_db.id,
|
||||
columns=[],
|
||||
)
|
||||
db.session.add(second_dataset)
|
||||
# Flush so `second_dataset.id` is populated before it's read below;
|
||||
# otherwise the chart would be constructed with `datasource_id=None`
|
||||
# and never actually link back to this dataset.
|
||||
db.session.flush()
|
||||
|
||||
# Create a chart using the second database's dataset
|
||||
chart_from_second_db = Slice(
|
||||
slice_name="Chart from Second Database",
|
||||
datasource_type="table",
|
||||
datasource_id=second_dataset.id,
|
||||
datasource_name=second_dataset.table_name,
|
||||
viz_type="bar",
|
||||
params=json.dumps({"viz_type": "bar"}),
|
||||
)
|
||||
db.session.add(chart_from_second_db)
|
||||
|
||||
# Get the example dashboard and add the new chart
|
||||
example_dashboard = (
|
||||
db.session.query(Dashboard).filter_by(slug="world_health").one()
|
||||
)
|
||||
|
||||
# Store original charts count
|
||||
original_charts_count = len(example_dashboard.slices)
|
||||
|
||||
# Add the new chart from different database to the dashboard
|
||||
example_dashboard.slices.append(chart_from_second_db)
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
# Export the dashboard
|
||||
command = ExportDashboardsCommand([example_dashboard.id])
|
||||
contents = dict(command.run())
|
||||
|
||||
# Verify all databases are exported
|
||||
db_files = [key for key in contents.keys() if key.startswith("databases/")]
|
||||
assert len(db_files) >= 2, (
|
||||
f"Expected at least 2 database files, got {db_files}"
|
||||
)
|
||||
|
||||
# Verify the second database is included
|
||||
assert "databases/test_db_2.yaml" in contents.keys(), (
|
||||
f"Second database not found in export. Keys: {list(contents.keys())}"
|
||||
)
|
||||
|
||||
# Verify all charts are exported (original + new one)
|
||||
chart_files = [key for key in contents.keys() if key.startswith("charts/")]
|
||||
assert len(chart_files) == original_charts_count + 1, (
|
||||
f"Expected {original_charts_count + 1} charts, got {len(chart_files)}"
|
||||
)
|
||||
|
||||
# Verify the new chart from second database is included
|
||||
chart_from_second_db_file = None
|
||||
for key in chart_files:
|
||||
if f"Chart_from_Second_Database_{chart_from_second_db.id}" in key:
|
||||
chart_from_second_db_file = key
|
||||
break
|
||||
|
||||
assert chart_from_second_db_file is not None, (
|
||||
f"Chart from second database not found in export. "
|
||||
f"Chart files: {chart_files}"
|
||||
)
|
||||
|
||||
# Verify the dataset from second database is included
|
||||
dataset_files = [
|
||||
key for key in contents.keys() if key.startswith("datasets/")
|
||||
]
|
||||
second_dataset_file = (
|
||||
f"datasets/test_db_2/second_dataset_{second_dataset.id}.yaml"
|
||||
)
|
||||
assert second_dataset_file in contents.keys(), (
|
||||
f"Second dataset not found. Dataset files: {dataset_files}"
|
||||
)
|
||||
finally:
|
||||
# Clean up, even if an assertion above failed, so a failing run
|
||||
# doesn't leave extra Database/Slice/SqlaTable rows for later tests.
|
||||
example_dashboard.slices.remove(chart_from_second_db)
|
||||
db.session.delete(chart_from_second_db)
|
||||
db.session.delete(second_dataset)
|
||||
db.session.delete(second_db)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestImportDashboardsCommand(SupersetTestCase):
|
||||
def test_import_v0_dashboard_cli_export(self):
|
||||
|
||||
@@ -2487,15 +2487,8 @@ class TestDatabaseApi(SupersetTestCase):
|
||||
}
|
||||
assert response == expected_response
|
||||
|
||||
# Uses a `dialect+driver://` URI (rather than the bare `broken://`
|
||||
# above) to also cover engine-name extraction stripping the driver
|
||||
# suffix. The dialect itself ("broken") must stay one that no
|
||||
# installed extra ever registers a real SQLAlchemy plugin for --
|
||||
# this PR's own testcontainers extras (mssql, oracle, db2, ...)
|
||||
# install real drivers for those dialects, which would make this
|
||||
# URI actually attempt a connection instead of failing to load.
|
||||
data = {
|
||||
"sqlalchemy_uri": "broken+driver://url",
|
||||
"sqlalchemy_uri": "mssql+pymssql://url",
|
||||
"database_name": "examples",
|
||||
"impersonate_user": False,
|
||||
"server_cert": None,
|
||||
@@ -2507,7 +2500,7 @@ class TestDatabaseApi(SupersetTestCase):
|
||||
expected_response = {
|
||||
"errors": [
|
||||
{
|
||||
"message": "Could not load database driver for: broken",
|
||||
"message": "Could not load database driver for: mssql",
|
||||
"error_type": "GENERIC_COMMAND_ERROR",
|
||||
"level": "warning",
|
||||
"extra": {
|
||||
|
||||
@@ -146,6 +146,36 @@ def test_csv_upload_dataset():
|
||||
assert user_is_editor(security_manager.find_user("admin"), dataset)
|
||||
|
||||
|
||||
@only_postgresql
|
||||
@pytest.mark.usefixtures("setup_csv_upload_with_context_schema")
|
||||
def test_csv_upload_dataset_catalog():
|
||||
admin_user = security_manager.find_user(username="admin")
|
||||
upload_database = get_upload_db()
|
||||
|
||||
with override_user(admin_user):
|
||||
UploadCommand(
|
||||
upload_database.id,
|
||||
CSV_UPLOAD_TABLE_W_SCHEMA,
|
||||
create_csv_file(CSV_FILE_1),
|
||||
"public",
|
||||
CSVReader({}),
|
||||
).run()
|
||||
|
||||
dataset = (
|
||||
db.session.query(SqlaTable)
|
||||
.filter_by(
|
||||
database_id=upload_database.id,
|
||||
table_name=CSV_UPLOAD_TABLE_W_SCHEMA,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
catalog = upload_database.get_default_catalog()
|
||||
assert dataset.catalog == catalog
|
||||
assert dataset.schema_perm == (
|
||||
f"[{upload_database.database_name}].[{catalog}].[public]"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_csv_upload_with_context")
|
||||
def test_csv_upload_with_index():
|
||||
admin_user = security_manager.find_user(username="admin")
|
||||
|
||||
@@ -1,16 +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.
|
||||
@@ -1,16 +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.
|
||||
@@ -1,47 +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.
|
||||
"""
|
||||
Shared import guard for the per-dialect testcontainers modules
|
||||
(tests/testcontainers/db_engine_specs/test_*.py), each of which needs its
|
||||
own optional `testcontainers[...]` driver submodule to even import.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
|
||||
|
||||
def require_driver(module_name: str) -> None:
|
||||
"""
|
||||
Import `module_name`, a dialect's `testcontainers` driver submodule.
|
||||
|
||||
Most environments treat that driver as optional: a bare local `pytest`
|
||||
run, or another CI job that never installed the `testcontainers` extras,
|
||||
should skip the module rather than fail collection outright.
|
||||
|
||||
The dedicated per-dialect CI job (.github/workflows/testcontainers.yml)
|
||||
sets SUPERSET_TESTCONTAINERS_STRICT, because there the driver is not
|
||||
optional -- that job's matrix installs exactly this one driver for
|
||||
exactly this one module. A broken or missing import there means the job
|
||||
is misconfigured, and should fail loudly instead of silently reporting
|
||||
a misleadingly green, zero-tests-run result.
|
||||
"""
|
||||
if os.environ.get("SUPERSET_TESTCONTAINERS_STRICT"):
|
||||
importlib.import_module(module_name)
|
||||
else:
|
||||
import pytest
|
||||
|
||||
pytest.importorskip(module_name)
|
||||
@@ -1,68 +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.
|
||||
"""
|
||||
Shared body for the "paginated query returns correct rows in order" test
|
||||
that db_engine_specs.{cockroachdb,crate,db2,mssql,oracle,trino}'s
|
||||
testcontainers suites each run against their own real instance: a plain
|
||||
SQLAlchemy Core LIMIT/OFFSET query, compiled and executed for real. Mocked
|
||||
tests cannot catch a dialect compiling this incorrectly (see
|
||||
apache/superset#42899, where Trino emitted OFFSET before LIMIT) -- only
|
||||
real execution can.
|
||||
|
||||
Each call site keeps its own test function (and dialect-specific docstring)
|
||||
so failures still report against the right module; this only factors out
|
||||
the identical table setup/assert body, via an optional post-insert hook for
|
||||
dialects (CrateDB) that need one, and an optional extra-table-args hook for
|
||||
dialects (ClickHouse) whose CREATE TABLE requires a schema item a plain
|
||||
Column/primary key can't express.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Column, insert, Integer, MetaData, select, Table as SATable
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
|
||||
|
||||
def assert_paginated_query_returns_correct_rows_in_order(
|
||||
engine: Engine,
|
||||
after_insert: Callable[[Connection], None] | None = None,
|
||||
extra_table_args: tuple[Any, ...] = (),
|
||||
) -> None:
|
||||
metadata = MetaData()
|
||||
t = SATable(
|
||||
"pilot_pagination",
|
||||
metadata,
|
||||
# autoincrement=False: a single-column integer primary key otherwise
|
||||
# implicitly becomes AUTO_INCREMENT on MySQL/MariaDB. That column
|
||||
# type treats an explicit 0 as NULL by default (NO_AUTO_VALUE_ON_ZERO
|
||||
# is off), so the id=0 row below would silently get auto-assigned 1,
|
||||
# colliding with the explicit id=1 row in the same batch insert.
|
||||
Column("id", Integer, primary_key=True, autoincrement=False),
|
||||
*extra_table_args,
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(insert(t), [{"id": i} for i in range(10)])
|
||||
if after_insert is not None:
|
||||
after_insert(conn)
|
||||
|
||||
with engine.connect() as conn:
|
||||
stmt = select(t.c.id).order_by(t.c.id).limit(3).offset(4)
|
||||
rows = conn.execute(stmt).fetchall()
|
||||
|
||||
assert [row.id for row in rows] == [4, 5, 6]
|
||||
@@ -1,130 +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.
|
||||
"""
|
||||
Tests db_engine_specs.clickhouse against a real ClickHouse instance, spun
|
||||
up on demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
Superset's recommended ClickHouse connector is `clickhouse-connect`
|
||||
(`ClickHouseConnectEngineSpec`, engine "clickhousedb"), which talks HTTP,
|
||||
not `ClickHouseContainer`'s own documented `clickhouse_driver` (a different,
|
||||
native-TCP-protocol package Superset doesn't use at all). The container
|
||||
exposes both the native TCP port (9000) and the HTTP port (8123); this test
|
||||
connects over the HTTP port to match Superset's actual driver.
|
||||
|
||||
Unlike every other dialect in this suite, ClickHouse tables have no real
|
||||
primary key/constraint concept -- CREATE TABLE requires an explicit engine
|
||||
(e.g. MergeTree), or clickhouse-connect's DDL compiler raises a CompileError
|
||||
rather than defaulting to one.
|
||||
|
||||
`superset.db_engine_specs.clickhouse` runs module-level setup code (default
|
||||
type-formatting overrides) that dereferences `current_app.config` whenever
|
||||
clickhouse-connect is installed, so importing it outside a Flask app context
|
||||
raises RuntimeError the first time it's imported in a process.
|
||||
`tests/unit_tests/db_engine_specs/test_clickhouse.py` gets an app context
|
||||
for free from that suite's autouse fixture; this suite has no such fixture,
|
||||
so this test pushes one explicitly around just that one-time import, reusing
|
||||
the real app instance `tests/conftest.py` already builds for the rest of the
|
||||
test run rather than constructing a second one.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.clickhouse")
|
||||
require_driver("clickhouse_connect")
|
||||
|
||||
from clickhouse_connect.cc_sqlalchemy.ddl.tableengine import MergeTree # noqa: E402
|
||||
from testcontainers.community.clickhouse import ClickHouseContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
HTTP_PORT = 8123
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with ClickHouseContainer("clickhouse/clickhouse-server:latest") as container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(HTTP_PORT)
|
||||
yield create_engine(
|
||||
f"clickhousedb://{container.username}:{container.password}"
|
||||
f"@{host}:{port}/{container.dbname}"
|
||||
)
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(
|
||||
engine, extra_table_args=(MergeTree(order_by="id"),)
|
||||
)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
ClickHouseConnectEngineSpec.get_columns wraps a real SQLAlchemy
|
||||
Inspector; this exercises that against actual server-reported column
|
||||
metadata rather than a mocked Inspector.
|
||||
"""
|
||||
from tests.integration_tests.test_app import app
|
||||
|
||||
with app.app_context():
|
||||
from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
|
||||
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
MergeTree(order_by="id"),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = ClickHouseConnectEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = ClickHouseConnectEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,97 +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.
|
||||
"""
|
||||
Tests db_engine_specs.cockroachdb against a real CockroachDB instance,
|
||||
spun up on demand via testcontainers. Run via
|
||||
.github/workflows/testcontainers.yml -- these exercise real SQL execution
|
||||
and dialect introspection, which mocked unit tests structurally cannot.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.cockroachdb import CockroachDbEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.cockroachdb")
|
||||
|
||||
from testcontainers.community.cockroachdb import CockroachDBContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
# sqlalchemy-cockroachdb registers its dialect under the plain
|
||||
# "cockroachdb" name; the container's own default ("cockroachdb+psycopg2")
|
||||
# matches the abandoned `cockroachdb` package instead (see #43501).
|
||||
with CockroachDBContainer(dialect="cockroachdb") as container:
|
||||
yield create_engine(container.get_connection_url())
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
CockroachDbEngineSpec.get_columns wraps a real SQLAlchemy Inspector;
|
||||
this exercises that against actual server-reported column metadata
|
||||
rather than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = CockroachDbEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = CockroachDbEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,103 +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.
|
||||
"""
|
||||
Tests db_engine_specs.crate against a real CrateDB instance, spun up on
|
||||
demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
crate/crate only publishes an amd64 image (no arm64 build), and requires a
|
||||
host CPU supporting the x86-64-v3 instruction set -- QEMU emulation on
|
||||
Apple Silicon cannot satisfy that, so this file cannot run locally on an
|
||||
Apple Silicon machine even with `docker pull --platform linux/amd64`. It
|
||||
runs natively on GitHub Actions' x86_64 runners.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
|
||||
from superset.db_engine_specs.crate import CrateEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.cratedb")
|
||||
|
||||
from testcontainers.community.cratedb import CrateDBContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with CrateDBContainer() as container:
|
||||
yield create_engine(container.get_connection_url())
|
||||
|
||||
|
||||
def _refresh_pilot_pagination(conn: Connection) -> None:
|
||||
# CrateDB is eventually consistent: a row is not guaranteed visible to
|
||||
# subsequent selects immediately after insert.
|
||||
conn.exec_driver_sql("REFRESH TABLE pilot_pagination")
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(
|
||||
engine, after_insert=_refresh_pilot_pagination
|
||||
)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
CrateEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = CrateEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = CrateEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
@@ -1,114 +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.
|
||||
"""
|
||||
Tests db_engine_specs.databend against a real Databend instance, spun up
|
||||
on demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
Databend has no dedicated testcontainers module, so this uses a generic
|
||||
DockerContainer against the official `datafuselabs/databend` standalone
|
||||
image. Superset's DatabendEngineSpec defaults to `sslmode=require`
|
||||
(`encryption_parameters`), but the local standalone image has no TLS
|
||||
listener, so this connects with `sslmode=disable` explicitly.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.databend import DatabendEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.core.container")
|
||||
require_driver("databend_sqlalchemy")
|
||||
|
||||
from testcontainers.core.container import DockerContainer # noqa: E402
|
||||
from testcontainers.core.wait_strategies import LogMessageWaitStrategy # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
HTTP_PORT = 8000
|
||||
DBNAME = "default"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
container = DockerContainer("datafuselabs/databend")
|
||||
container.with_exposed_ports(HTTP_PORT)
|
||||
# The image's own startup banner documents this exact line as proof its
|
||||
# HTTP query endpoint is bound and ready.
|
||||
container.waiting_for(LogMessageWaitStrategy(f"listened at 0.0.0.0:{HTTP_PORT}"))
|
||||
|
||||
with container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(HTTP_PORT)
|
||||
# "root" with no password is the image's builtin user -- confirmed
|
||||
# directly against a running container, not from the image's own
|
||||
# doc text, which only shows ${USER}/${PASSWORD} placeholders.
|
||||
yield create_engine(f"databend://root:@{host}:{port}/{DBNAME}?sslmode=disable")
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
DatabendEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = DatabendEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = DatabendEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,95 +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.
|
||||
"""
|
||||
Tests db_engine_specs.db2 against a real IBM Db2 instance, spun up on
|
||||
demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
icr.io/db2_community/db2 only publishes amd64/ppc64le/s390x images (no
|
||||
arm64 build), so this cannot run locally on an Apple Silicon machine. It
|
||||
runs natively on GitHub Actions' x86_64 runners. Db2 is also a notably slow
|
||||
starter (a full instance bring-up, not just a process start) -- expect this
|
||||
module alone to take several minutes.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.db2 import Db2EngineSpec
|
||||
from superset.sql.parse import Table
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.db2")
|
||||
|
||||
from testcontainers.community.db2 import Db2Container # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with Db2Container() as container:
|
||||
yield create_engine(container.get_connection_url())
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
Db2EngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = Db2EngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = Db2EngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
@@ -1,107 +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.
|
||||
"""
|
||||
Tests db_engine_specs.elasticsearch against a real Elasticsearch instance,
|
||||
spun up on demand via testcontainers. Run via
|
||||
.github/workflows/testcontainers.yml.
|
||||
|
||||
Unlike the SQL-native dialects in this directory, Elasticsearch has no
|
||||
CREATE TABLE / INSERT: indices and documents get created via its REST API
|
||||
(elasticsearch-dbapi's SQLAlchemy dialect is read-focused, translating SQL
|
||||
to the _sql endpoint), matching how Superset actually encounters
|
||||
Elasticsearch in practice -- data arrives via ingestion tooling, not
|
||||
through Superset itself.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.elasticsearch import ElasticSearchEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.elasticsearch")
|
||||
|
||||
from testcontainers.community.elasticsearch import ElasticSearchContainer # noqa: E402
|
||||
|
||||
INDEX = "pilot_pagination"
|
||||
|
||||
|
||||
def _index_document(
|
||||
base_url: str, index: str, doc_id: int, body: dict[str, int]
|
||||
) -> None:
|
||||
response = requests.put(f"{base_url}/{index}/_doc/{doc_id}", json=body, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def _refresh(base_url: str, index: str) -> None:
|
||||
response = requests.post(f"{base_url}/{index}/_refresh", timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with ElasticSearchContainer("elasticsearch:8.11.0") as container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(container.port)
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
for i in range(10):
|
||||
_index_document(base_url, INDEX, i, {"id": i})
|
||||
_refresh(base_url, INDEX)
|
||||
|
||||
yield create_engine(f"elasticsearch+http://{host}:{port}/")
|
||||
|
||||
|
||||
def test_ordered_limited_query_returns_correct_rows(engine: Engine) -> None:
|
||||
"""
|
||||
A plain LIMIT query, compiled and executed against a real instance.
|
||||
Mocked tests cannot catch a dialect compiling this incorrectly (see
|
||||
apache/superset#42899, where Trino emitted OFFSET before LIMIT) -- only
|
||||
real execution can. No OFFSET here: Elasticsearch's SQL layer genuinely
|
||||
doesn't support it (a protocol limitation, not a bug -- confirmed
|
||||
against a real instance, which raises a parsing_exception on OFFSET).
|
||||
ElasticSearchEngineSpec.supports_offset = False documents this already.
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(f"SELECT id FROM {INDEX} ORDER BY id LIMIT 3") # noqa: S608
|
||||
).fetchall()
|
||||
|
||||
assert [row.id for row in rows] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
ElasticSearchEngineSpec.get_columns wraps a real SQLAlchemy Inspector;
|
||||
this exercises that against actual server-reported field mappings
|
||||
rather than a mocked Inspector.
|
||||
"""
|
||||
inspector = inspect(engine)
|
||||
columns = ElasticSearchEngineSpec.get_columns(inspector, Table(INDEX))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert "id" in by_name
|
||||
spec = ElasticSearchEngineSpec.get_column_spec(str(by_name["id"]["type"]))
|
||||
assert spec is not None
|
||||
@@ -1,146 +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.
|
||||
"""
|
||||
Tests db_engine_specs.firebird against a real Firebird instance, spun up
|
||||
on demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
Firebird references a database *file* rather than a server-managed named
|
||||
database -- the connection URI is `firebird://user:pass@host:port/<path>`,
|
||||
where <path> is the path to a .fdb file on the server. This uses the
|
||||
well-known `jacobalberty/firebird` image, which creates that file (per
|
||||
FIREBIRD_DATABASE) under /firebird/data on first boot.
|
||||
|
||||
FirebirdEngineSpec sets `limit_method = LimitMethod.FETCH_MANY` with a
|
||||
comment claiming Firebird "uses FIRST to limit" -- stale relative to the
|
||||
modern sqlalchemy-firebird driver, which compiles real ROWS-based
|
||||
pagination (confirmed via an offline dialect compile: `SELECT ... ROWS
|
||||
4 + 1 TO 4 + 3`, correctly ordered, not a Trino-style bug). That staleness
|
||||
affects what Superset's own query layer emits, not what this suite's
|
||||
direct dialect-compilation check exercises.
|
||||
|
||||
Could not be verified against a real running instance in this
|
||||
environment: `firebird-driver` is a pure-Python ctypes wrapper (its wheel
|
||||
is `py3-none-any`, confirmed by downloading it directly) that dynamically
|
||||
loads the native Firebird client library (`libfbclient`) from the host at
|
||||
import time -- it doesn't bundle that library itself. This machine has no
|
||||
Homebrew formula or straightforward install path for it. The container
|
||||
itself was confirmed to start and pass its own healthcheck; CI installs
|
||||
the `libfbclient2` system package separately (see
|
||||
.github/workflows/testcontainers.yml) for the actual client-library
|
||||
dependency this driver needs.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.firebird import FirebirdEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.core.container")
|
||||
require_driver("firebird.driver")
|
||||
|
||||
from testcontainers.core.container import DockerContainer # noqa: E402
|
||||
from testcontainers.core.wait_strategies import HealthcheckWaitStrategy # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
PORT = 3050
|
||||
PASSWORD = "masterkey" # noqa: S105 -- fixed test-fixture password, not a secret
|
||||
DB_FILE = "test.fdb"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
container = DockerContainer("jacobalberty/firebird")
|
||||
container.with_exposed_ports(PORT)
|
||||
container.with_env("ISC_PASSWORD", PASSWORD)
|
||||
container.with_env("FIREBIRD_DATABASE", DB_FILE)
|
||||
# The image logs nothing beyond a single startup banner line and never
|
||||
# prints a distinct "ready" message -- it ships its own Docker
|
||||
# HEALTHCHECK instead, confirmed via `docker ps` reporting (healthy).
|
||||
container.waiting_for(HealthcheckWaitStrategy())
|
||||
|
||||
with container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(PORT)
|
||||
eng = create_engine(
|
||||
f"firebird://sysdba:{PASSWORD}@{host}:{port}//firebird/data/{DB_FILE}"
|
||||
)
|
||||
yield eng
|
||||
# firebird-driver registers its own atexit handler that talks to
|
||||
# the Firebird subsystem to shut it down cleanly. Without disposing
|
||||
# here first, that handler fires at interpreter exit against a
|
||||
# server the container has *already* torn down -- confirmed on
|
||||
# real CI as a segfault (exit code 139) after both tests had
|
||||
# already passed. Disposing while the server is still up lets the
|
||||
# driver close out normally, so the later atexit call has nothing
|
||||
# left to talk to.
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
FirebirdEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = FirebirdEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = FirebirdEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,117 +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.
|
||||
"""
|
||||
Tests db_engine_specs.mariadb against a real MariaDB instance, spun up on
|
||||
demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
MariaDB is a MySQL fork implementing the same wire protocol: connects via
|
||||
the plain "mysql" dialect with mysqlclient, same as vanilla MySQL, just
|
||||
pointed at the mariadb image instead of mysql:latest.
|
||||
|
||||
Could not be verified locally in this environment: mysqlclient (MySQLdb)
|
||||
has a pre-existing, unrelated native-library linking issue against this
|
||||
machine's Homebrew-installed libmysqlclient. CI installs it via apt on
|
||||
Linux, where this does not occur.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.mariadb import MariaDBEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.mysql")
|
||||
|
||||
from testcontainers.community.mysql import MySqlContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with MySqlContainer("mariadb:11") as container:
|
||||
# get_connection_url() has no host override and defaults to
|
||||
# get_container_host_ip(), which is the literal string "localhost"
|
||||
# on native Linux Docker (e.g. GitHub Actions runners). MySQLdb
|
||||
# (mysqlclient) treats a "localhost" host specially and attempts a
|
||||
# Unix socket connection instead of TCP, which fails since there's
|
||||
# no local MySQL socket -- the container is reached over the
|
||||
# network. Only rewrite that specific local case to 127.0.0.1; a
|
||||
# remote Docker daemon reports its own real host/IP here, which
|
||||
# must be preserved so the suite can still reach it.
|
||||
host = container.get_container_host_ip()
|
||||
if host == "localhost":
|
||||
host = "127.0.0.1"
|
||||
port = container.get_exposed_port(container.port)
|
||||
yield create_engine(
|
||||
f"mysql://{container.username}:{container.password}"
|
||||
f"@{host}:{port}/{container.dbname}"
|
||||
)
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
MariaDBEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = MariaDBEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = MariaDBEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,127 +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.
|
||||
"""
|
||||
Tests db_engine_specs.monetdb against a real MonetDB instance, spun up on
|
||||
demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
monetdb/monetdb publishes an amd64-only image, so this needs Rosetta/QEMU
|
||||
emulation on Apple Silicon -- unlike CrateDB's x86-64-v3 CPU requirement,
|
||||
this one actually runs fine under emulation (verified locally). No native
|
||||
testcontainers module exists for MonetDB, so this uses a generic
|
||||
DockerContainer with the documented MDB_* environment variables and waits
|
||||
for the daemon's own startup log line.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.monetdb import MonetDbEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.core.container")
|
||||
require_driver("sqlalchemy_monetdb")
|
||||
|
||||
from testcontainers.core.container import DockerContainer # noqa: E402
|
||||
from testcontainers.core.wait_strategies import ( # noqa: E402
|
||||
CompositeWaitStrategy,
|
||||
LogMessageWaitStrategy,
|
||||
PortWaitStrategy,
|
||||
)
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
PORT = 50000
|
||||
PASSWORD = "monetdb" # noqa: S105 -- fixed test-fixture password, not a secret
|
||||
DBNAME = "test"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
container = DockerContainer("monetdb/monetdb:latest")
|
||||
container.with_exposed_ports(PORT)
|
||||
container.with_env("MDB_DB_ADMIN_PASS", PASSWORD)
|
||||
container.with_env("MDB_CREATE_DBS", DBNAME)
|
||||
# The "Starting MonetDB daemon" log line is emitted before the image
|
||||
# actually runs `monetdbd start -n`, so it alone isn't proof the server
|
||||
# is accepting connections yet. Follow it with a port-connect check,
|
||||
# which only succeeds once monetdbd is really listening.
|
||||
container.waiting_for(
|
||||
CompositeWaitStrategy(
|
||||
LogMessageWaitStrategy(re.compile("Starting MonetDB daemon")),
|
||||
PortWaitStrategy(PORT),
|
||||
)
|
||||
)
|
||||
|
||||
with container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(PORT)
|
||||
yield create_engine(f"monetdb://monetdb:{PASSWORD}@{host}:{port}/{DBNAME}")
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
MonetDbEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = MonetDbEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = MonetDbEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,141 +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.
|
||||
"""
|
||||
Tests db_engine_specs.mongodb against a real MongoDB instance, spun up on
|
||||
demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
MongoDB is schemaless, and Superset talks to it via `pymongosql`, a
|
||||
SQL-to-MongoDB translation layer (dialect requires a `?mode=superset` query
|
||||
param -- not part of testcontainers' own MongoDbContainer.get_connection_url()).
|
||||
Documents get inserted via the native pymongo driver, not SQL INSERT,
|
||||
matching how Superset actually encounters MongoDB in practice and avoiding
|
||||
any assumption about pymongosql's own INSERT/DDL support.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
select,
|
||||
table,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.mongodb import MongoDBEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.mongodb")
|
||||
require_driver("pymongosql")
|
||||
|
||||
from testcontainers.community.mongodb import MongoDbContainer # noqa: E402
|
||||
|
||||
COLLECTION = "pilot_pagination"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with MongoDbContainer("mongo:7.0.7") as container:
|
||||
client = container.get_connection_client()
|
||||
client[container.dbname][COLLECTION].insert_many([{"id": i} for i in range(10)])
|
||||
# MongoDbContainer.get_connection_url() has no database path segment
|
||||
# or query string at all (it only builds user:pass@host:port), so
|
||||
# naively appending "&mode=superset" glues it straight onto the port
|
||||
# number instead of starting a query string. Build the full URL
|
||||
# ourselves instead of relying on string concatenation.
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(container.port)
|
||||
# authSource=admin is required: MongoDbContainer creates its root
|
||||
# user via MONGO_INITDB_ROOT_USERNAME, which lives in the `admin`
|
||||
# database, not in `dbname` -- without it, auth fails against
|
||||
# whatever database is in the URL path.
|
||||
yield create_engine(
|
||||
f"mongodb://{container.username}:{container.password}@{host}:{port}"
|
||||
f"/{container.dbname}?mode=superset&authSource=admin"
|
||||
)
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed
|
||||
against a real instance. Mocked tests cannot catch a dialect compiling
|
||||
this incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can. Unlike Elasticsearch's SQL
|
||||
layer (which has no OFFSET support at all), pymongosql maps OFFSET to
|
||||
MongoDB's native `skip`, so this dialect supports it.
|
||||
|
||||
Compiled with `literal_binds=True`, matching how Superset actually
|
||||
issues chart/SQL Lab queries (see `models/helpers.py`'s
|
||||
`get_query_str_extended`): pymongosql's SQL-to-Mongo AST parser reads
|
||||
LIMIT/OFFSET straight off the compiled SQL text ahead of parameter
|
||||
substitution, so a bound `LIMIT ?`/`OFFSET ?` placeholder is rejected
|
||||
("invalid literal for int() with base 10: '?'") and the clause is
|
||||
silently dropped -- unlike its WHERE-clause parameter handling, which
|
||||
does substitute correctly. Literal binds sidestep that and exercise
|
||||
the dialect's actual LIMIT/OFFSET compilation, per this test's intent.
|
||||
|
||||
Uses a bare `column("id")`/`table(...)` pair rather than a full
|
||||
`Table`-bound column: SQLAlchemy always qualifies a Table-bound column
|
||||
reference as `pilot_pagination.id` once there's a FROM clause, and
|
||||
pymongosql's projection builder takes that qualified text completely
|
||||
literally as a MongoDB field path -- `{"pilot_pagination.id": 1}` reads
|
||||
a *nested* field under a top-level `pilot_pagination` key, which
|
||||
doesn't exist on these flat documents, silently projecting None instead
|
||||
of raising. An unbound column compiles unqualified ("id"), which
|
||||
resolves correctly, while still exercising the dialect's own
|
||||
LIMIT/OFFSET compilation via a real Core `select()`.
|
||||
"""
|
||||
id_col = column("id")
|
||||
stmt = (
|
||||
select(id_col)
|
||||
.select_from(table(COLLECTION))
|
||||
.order_by(id_col)
|
||||
.limit(3)
|
||||
.offset(4)
|
||||
)
|
||||
compiled = stmt.compile(engine, compile_kwargs={"literal_binds": True})
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text(str(compiled))).fetchall()
|
||||
|
||||
assert [row.id for row in rows] == [4, 5, 6]
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
MongoDBEngineSpec.get_columns wraps a real SQLAlchemy Inspector, which
|
||||
pymongosql implements by sampling real documents to infer column types
|
||||
-- this exercises that against an actual running instance rather than
|
||||
a mocked Inspector.
|
||||
"""
|
||||
inspector = inspect(engine)
|
||||
columns = MongoDBEngineSpec.get_columns(inspector, Table(COLLECTION))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert "id" in by_name
|
||||
spec = MongoDBEngineSpec.get_column_spec(str(by_name["id"]["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,93 +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.
|
||||
"""
|
||||
Tests db_engine_specs.mssql against a real SQL Server instance, spun up on
|
||||
demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
mcr.microsoft.com/mssql/server only publishes an amd64 image (SQL Server on
|
||||
Linux has no ARM build), so this cannot run locally on an Apple Silicon
|
||||
machine. It runs natively on GitHub Actions' x86_64 runners.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.mssql import MssqlEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.mssql")
|
||||
|
||||
from testcontainers.community.mssql import SqlServerContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with SqlServerContainer() as container:
|
||||
yield create_engine(container.get_connection_url())
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
MssqlEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = MssqlEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = MssqlEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
@@ -1,117 +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.
|
||||
"""
|
||||
Tests db_engine_specs.mysql against a real MySQL instance, spun up on
|
||||
demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
Plain MySQL itself was never covered by this suite: MariaDB and StarRocks
|
||||
both reuse `MySQLEngineSpec`'s plain "mysql" dialect via mysqlclient, but
|
||||
neither stands in for vanilla MySQL server's own dialect quirks.
|
||||
|
||||
Could not be verified locally in this environment: mysqlclient (MySQLdb)
|
||||
has a pre-existing, unrelated native-library linking issue against this
|
||||
machine's Homebrew-installed libmysqlclient. CI installs it via apt on
|
||||
Linux, where this does not occur.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.mysql import MySQLEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.mysql")
|
||||
|
||||
from testcontainers.community.mysql import MySqlContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with MySqlContainer("mysql:8.0") as container:
|
||||
# get_connection_url() has no host override and defaults to
|
||||
# get_container_host_ip(), which is the literal string "localhost"
|
||||
# on native Linux Docker (e.g. GitHub Actions runners). MySQLdb
|
||||
# (mysqlclient) treats a "localhost" host specially and attempts a
|
||||
# Unix socket connection instead of TCP, which fails since there's
|
||||
# no local MySQL socket -- the container is reached over the
|
||||
# network. Only rewrite that specific local case to 127.0.0.1; a
|
||||
# remote Docker daemon reports its own real host/IP here, which
|
||||
# must be preserved so the suite can still reach it.
|
||||
host = container.get_container_host_ip()
|
||||
if host == "localhost":
|
||||
host = "127.0.0.1"
|
||||
port = container.get_exposed_port(container.port)
|
||||
yield create_engine(
|
||||
f"mysql://{container.username}:{container.password}"
|
||||
f"@{host}:{port}/{container.dbname}"
|
||||
)
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
MySQLEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = MySQLEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = MySQLEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,221 +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.
|
||||
"""
|
||||
Tests db_engine_specs.oceanbase against a real OceanBase instance, spun up
|
||||
on demand via testcontainers. Run via .github/workflows/testcontainers.yml,
|
||||
on the nightly cron / manual dispatch only (see `nightly_only: true` on
|
||||
this dialect's matrix entry) -- OceanBase bootstraps a distributed-style
|
||||
cluster even in single-node MODE=MINI, a substantially heavier first-boot
|
||||
than a single-process database, not a good fit for every PR's CI budget.
|
||||
|
||||
OceanBaseEngineSpec extends MySQLEngineSpec and its dialect
|
||||
(oceanbase_py.sqlalchemy.dialect.OceanBaseDialect) extends
|
||||
MySQLDialect_mysqldb directly with no custom DDL or LIMIT/OFFSET compiler,
|
||||
so this follows the same mysqlclient-based pattern as MariaDB/MySQL/
|
||||
StarRocks in this suite -- including the same "localhost" -> "127.0.0.1"
|
||||
fix MySQLdb needs on native Linux Docker.
|
||||
|
||||
Could not be verified locally in this environment: mysqlclient (MySQLdb)
|
||||
has a pre-existing, unrelated native-library linking issue against this
|
||||
machine's Homebrew-installed libmysqlclient, and this dialect wasn't
|
||||
pulled/run locally at all given its heavier resource footprint -- CI-only
|
||||
verification, matching the nightly_only gating.
|
||||
|
||||
oceanbase_py.sqlalchemy.dialect.OceanBaseDialect has real bugs, all
|
||||
confirmed on real CI, in both has_table() (called by create_all()'s
|
||||
default checkfirst=True) and get_columns() (called by
|
||||
OceanBaseEngineSpec.get_columns(), which this suite's second test needs
|
||||
to actually exercise):
|
||||
1. Both pass a raw string straight to Connection.execute() (e.g.
|
||||
`connection.execute(f"DESCRIBE {full_name}")`), which SQLAlchemy 2.0
|
||||
rejects outright (ObjectNotExecutableError). Every *other* raw-SQL
|
||||
method in the same dialect module correctly uses
|
||||
`connection.exec_driver_sql(...)` instead.
|
||||
2. has_table() never catches the error DESCRIBE raises for a table that
|
||||
doesn't exist (1146) -- so even with (1) fixed, it can only ever
|
||||
return True, raising instead of returning False for exactly the case
|
||||
checkfirst exists to handle.
|
||||
This test monkeypatches both methods to do what the rest of the dialect's
|
||||
raw-SQL methods already do, plus has_table()'s missing not-found
|
||||
handling, rather than working around any of this from the test side.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Connection, Engine, URL
|
||||
from sqlalchemy.exc import NoSuchTableError, ProgrammingError
|
||||
|
||||
from superset.db_engine_specs.oceanbase import OceanBaseEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.core.container")
|
||||
require_driver("oceanbase_py")
|
||||
|
||||
from oceanbase_py.sqlalchemy import datatype # noqa: E402
|
||||
from oceanbase_py.sqlalchemy.dialect import OceanBaseDialect # noqa: E402
|
||||
from testcontainers.core.container import DockerContainer # noqa: E402
|
||||
from testcontainers.core.wait_strategies import LogMessageWaitStrategy # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
def _has_table(
|
||||
self: OceanBaseDialect,
|
||||
connection: Connection,
|
||||
table_name: str,
|
||||
schema: str | None = None,
|
||||
**kw: object,
|
||||
) -> bool:
|
||||
if schema is None:
|
||||
schema = self.default_schema_name
|
||||
quote = self.identifier_preparer.quote_identifier
|
||||
full_name = quote(table_name)
|
||||
if schema:
|
||||
full_name = f"{quote(schema)}.{full_name}"
|
||||
try:
|
||||
res = connection.exec_driver_sql(f"DESCRIBE {full_name}")
|
||||
except ProgrammingError:
|
||||
# The original never catches this at all -- DESCRIBE on a
|
||||
# nonexistent table raises 1146 ("table doesn't exist") rather
|
||||
# than returning an empty result set, so the unpatched method can
|
||||
# only ever return True, and raises instead of returning False for
|
||||
# exactly the case create_all()'s checkfirst exists to handle
|
||||
# (confirmed on real CI).
|
||||
return False
|
||||
return res.first() is not None
|
||||
|
||||
|
||||
def _get_columns(
|
||||
self: OceanBaseDialect,
|
||||
connection: Connection,
|
||||
table_name: str,
|
||||
schema: str | None = None,
|
||||
**kw: object,
|
||||
) -> list[dict[str, Any]]:
|
||||
# Same connection.execute(raw string) anti-pattern as has_table(),
|
||||
# confirmed on real CI as the same ObjectNotExecutableError -- this is
|
||||
# the actual column-introspection call OceanBaseEngineSpec.get_columns
|
||||
# (and this test) needs, so it gets the same exec_driver_sql fix.
|
||||
if not self.has_table(connection, table_name, schema):
|
||||
raise NoSuchTableError(f"schema={schema}, table={table_name}")
|
||||
schema = schema or self._get_default_schema_name(connection)
|
||||
|
||||
quote = self.identifier_preparer.quote_identifier
|
||||
full_name = quote(table_name)
|
||||
if schema:
|
||||
full_name = f"{quote(schema)}.{full_name}"
|
||||
|
||||
res = connection.exec_driver_sql(f"SHOW COLUMNS FROM {full_name}")
|
||||
return [
|
||||
{
|
||||
"name": record.Field,
|
||||
"type": datatype.parse_sql_type(record.Type),
|
||||
"nullable": record.Null == "YES",
|
||||
"default": record.Default,
|
||||
}
|
||||
for record in res
|
||||
]
|
||||
|
||||
|
||||
OceanBaseDialect.has_table = _has_table
|
||||
OceanBaseDialect.get_columns = _get_columns
|
||||
|
||||
PORT = 2881
|
||||
PASSWORD = "pilot" # noqa: S105 -- fixed test-fixture password, not a secret
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
container = DockerContainer("oceanbase/oceanbase-ce")
|
||||
container.with_exposed_ports(PORT)
|
||||
container.with_env("MODE", "MINI")
|
||||
container.with_env("OB_TENANT_PASSWORD", PASSWORD)
|
||||
container.waiting_for(LogMessageWaitStrategy("boot success!"))
|
||||
|
||||
with container:
|
||||
host = container.get_container_host_ip()
|
||||
if host == "localhost":
|
||||
host = "127.0.0.1"
|
||||
port = container.get_exposed_port(PORT)
|
||||
# OceanBase usernames for a MySQL-mode tenant use "user@tenant"
|
||||
# (e.g. "root@test"), a literal "@" that URL.create() percent-encodes
|
||||
# correctly -- an f-string would produce a second "@" that breaks
|
||||
# the URL's own host/user boundary parsing.
|
||||
yield create_engine(
|
||||
URL.create(
|
||||
"oceanbase",
|
||||
username="root@test",
|
||||
password=PASSWORD,
|
||||
host=host,
|
||||
port=int(port),
|
||||
database="test",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
OceanBaseEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = OceanBaseEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = OceanBaseEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,95 +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.
|
||||
"""
|
||||
Tests db_engine_specs.oracle against a real Oracle instance (gvenzl/oracle-free),
|
||||
spun up on demand via testcontainers. Run via
|
||||
.github/workflows/testcontainers.yml.
|
||||
|
||||
gvenzl/oracle-free ships with its datafiles pre-baked into the image, so
|
||||
once the (large-ish, ~1GB) image is pulled, container startup is fast --
|
||||
under 15s measured locally. Almost all the wall-clock cost here is the
|
||||
image pull itself, same as any other dialect's container.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.oracle import OracleEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.oracle")
|
||||
|
||||
from testcontainers.community.oracle import OracleDbContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with OracleDbContainer() as container:
|
||||
yield create_engine(container.get_connection_url())
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
OracleEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = OracleEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = OracleEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
@@ -1,99 +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.
|
||||
"""
|
||||
Tests db_engine_specs.postgres against a real PostgreSQL instance, spun up
|
||||
on demand via testcontainers. Run via .github/workflows/testcontainers.yml
|
||||
-- these exercise real SQL execution and dialect introspection, which
|
||||
mocked unit tests structurally cannot.
|
||||
|
||||
Plain Postgres itself was never covered by this suite: CockroachDB,
|
||||
TimescaleDB and YugabyteDB all speak the Postgres wire protocol and already
|
||||
exercise `PostgresContainer`/the "postgresql" dialect, but none of them
|
||||
stand in for vanilla PostgreSQL's own dialect quirks.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.postgres import PostgresEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.postgres")
|
||||
|
||||
from testcontainers.community.postgres import PostgresContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with PostgresContainer("postgres:17-alpine") as container:
|
||||
yield create_engine(container.get_connection_url())
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
PostgresEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = PostgresEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = PostgresEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,127 +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.
|
||||
"""
|
||||
Tests db_engine_specs.risingwave against a real RisingWave instance, spun
|
||||
up on demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
RisingWave speaks the Postgres wire protocol, but doesn't run the real
|
||||
Postgres server binary or its POSTGRES_PASSWORD-style bootstrap env vars,
|
||||
so this can't reuse `PostgresContainer` the way TimescaleDB/YugabyteDB do
|
||||
-- it needs a generic DockerContainer against the official
|
||||
`risingwavelabs/risingwave` single-binary playground image instead.
|
||||
`RisingWaveDbEngineSpec` extends `PostgresEngineSpec`, and
|
||||
`sqlalchemy-risingwave`'s dialect is a genuine subclass of SQLAlchemy's own
|
||||
Postgres dialect (via psycopg2), so DDL/pagination compile with standard
|
||||
Postgres semantics -- no ClickHouse-style mandatory table option needed.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
|
||||
from superset.db_engine_specs.risingwave import RisingWaveDbEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.core.container")
|
||||
require_driver("sqlalchemy_risingwave")
|
||||
|
||||
from testcontainers.core.container import DockerContainer # noqa: E402
|
||||
from testcontainers.core.wait_strategies import LogMessageWaitStrategy # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
PORT = 4566
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
container = DockerContainer("risingwavelabs/risingwave")
|
||||
container.with_exposed_ports(PORT)
|
||||
container.with_command("playground")
|
||||
# The actual startup banner reads "RisingWave standalone mode is
|
||||
# ready." -- confirmed against a real container's logs.
|
||||
container.waiting_for(
|
||||
LogMessageWaitStrategy(re.compile("RisingWave standalone mode is ready"))
|
||||
)
|
||||
|
||||
with container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(PORT)
|
||||
yield create_engine(f"risingwave://root@{host}:{port}/dev")
|
||||
|
||||
|
||||
def _flush(conn: Connection) -> None:
|
||||
# RisingWave's storage engine checkpoints asynchronously: without an
|
||||
# explicit FLUSH, a SELECT immediately after INSERT can see zero rows
|
||||
# -- confirmed against a real instance (a bare INSERT commits fine, but
|
||||
# the data isn't visible to a subsequent query until flushed).
|
||||
conn.execute(text("FLUSH"))
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine, after_insert=_flush)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
RisingWaveDbEngineSpec.get_columns wraps a real SQLAlchemy Inspector;
|
||||
this exercises that against actual server-reported column metadata
|
||||
rather than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = RisingWaveDbEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = RisingWaveDbEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,156 +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.
|
||||
"""
|
||||
Tests db_engine_specs.starrocks against a real StarRocks instance, spun up
|
||||
on demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
StarRocks has no dedicated testcontainers module, so this uses a generic
|
||||
DockerContainer against the official `starrocks/allin1-ubuntu` image, which
|
||||
brings up both the FE (query frontend, MySQL wire protocol on port 9030)
|
||||
and BE (execution backend) in a single container -- a heavier bring-up than
|
||||
a single-process database. `root` has no password by default and no
|
||||
database exists yet, so the fixture creates one itself before yielding an
|
||||
engine pointed at it. The FE's query port accepts connections, and can even
|
||||
run metadata statements like CREATE DATABASE, before the BE has registered
|
||||
with it -- an actual CREATE TABLE/INSERT then fails with "Backend node not
|
||||
found" -- so the fixture retries a real create-table-and-insert probe
|
||||
against a throwaway table rather than trusting the open port or a bare
|
||||
CREATE DATABASE as a readiness signal.
|
||||
|
||||
Not verified locally in this environment: the `allin1-ubuntu` image is
|
||||
multiple GB and was skipped here to keep local Docker resource usage low,
|
||||
per session guidance to lean on CI (which has no such constraint) for
|
||||
dialects with unusually heavy images.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.starrocks import StarRocksEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.core.container")
|
||||
require_driver("starrocks")
|
||||
|
||||
from testcontainers.core.container import DockerContainer # noqa: E402
|
||||
from testcontainers.core.wait_strategies import PortWaitStrategy # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
QUERY_PORT = 9030
|
||||
DBNAME = "pilot"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
container = DockerContainer("starrocks/allin1-ubuntu")
|
||||
container.with_exposed_ports(QUERY_PORT)
|
||||
container.waiting_for(PortWaitStrategy(QUERY_PORT))
|
||||
|
||||
with container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(QUERY_PORT)
|
||||
bootstrap_engine = create_engine(
|
||||
f"starrocks://root:@{host}:{port}/default_catalog.information_schema"
|
||||
)
|
||||
|
||||
# The FE's query port accepts connections, and can even run metadata
|
||||
# statements like CREATE DATABASE, before any BE (execution backend)
|
||||
# has registered with it -- an actual table create/insert then fails
|
||||
# with "Backend node not found". Probe with the real operations the
|
||||
# tests below need, in a throwaway table, so readiness is confirmed
|
||||
# for what actually matters rather than just the FE's own port.
|
||||
last_error: Exception | None = None
|
||||
for _ in range(60):
|
||||
try:
|
||||
with bootstrap_engine.begin() as conn:
|
||||
conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {DBNAME}"))
|
||||
probe_engine = create_engine(
|
||||
f"starrocks://root:@{host}:{port}/default_catalog.{DBNAME}"
|
||||
)
|
||||
with probe_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("CREATE TABLE IF NOT EXISTS pilot_ready (id INT)")
|
||||
)
|
||||
conn.execute(text("INSERT INTO pilot_ready VALUES (1)"))
|
||||
conn.execute(text("DROP TABLE pilot_ready"))
|
||||
break
|
||||
except Exception as ex: # noqa: BLE001 -- retry on any not-ready-yet error
|
||||
last_error = ex
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"StarRocks FE/BE never became ready to create and use a table"
|
||||
) from last_error
|
||||
|
||||
yield create_engine(f"starrocks://root:@{host}:{port}/default_catalog.{DBNAME}")
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
StarRocksEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = StarRocksEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = StarRocksEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,98 +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.
|
||||
"""
|
||||
Tests db_engine_specs.timescaledb against a real TimescaleDB instance,
|
||||
spun up on demand via testcontainers. Run via
|
||||
.github/workflows/testcontainers.yml -- these exercise real SQL execution
|
||||
and dialect introspection, which mocked unit tests structurally cannot.
|
||||
|
||||
TimescaleDB is a genuine Postgres extension, not a fork: connects via the
|
||||
plain "postgresql" dialect with psycopg2, same as vanilla Postgres, just
|
||||
pointed at the timescale/timescaledb image instead of postgres:latest.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.timescaledb import TimescaleDBEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.postgres")
|
||||
|
||||
from testcontainers.community.postgres import PostgresContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with PostgresContainer("timescale/timescaledb:2.29.2-pg16") as container:
|
||||
yield create_engine(container.get_connection_url())
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
TimescaleDBEngineSpec.get_columns wraps a real SQLAlchemy Inspector;
|
||||
this exercises that against actual server-reported column metadata
|
||||
rather than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = TimescaleDBEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = TimescaleDBEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,104 +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.
|
||||
"""
|
||||
Tests db_engine_specs.trino against a real Trino instance, spun up on
|
||||
demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
Only Presto is covered by existing docker-compose-based integration CI;
|
||||
Trino, despite sharing lineage with Presto, is not.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.trino import TrinoEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.community.trino")
|
||||
|
||||
from testcontainers.community.trino import TrinoContainer # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
with TrinoContainer() as container:
|
||||
# TrinoContainer.get_connection_url() (testcontainers 4.15.0) returns
|
||||
# the container-internal port (e.g. 8080) instead of the Docker-
|
||||
# mapped host port, so the URL it builds cannot actually connect.
|
||||
# Build it manually with get_exposed_port() instead. Filed upstream:
|
||||
# https://github.com/testcontainers/testcontainers-python/issues
|
||||
url = (
|
||||
f"trino://{container.user}@{container.get_container_host_ip()}"
|
||||
f":{container.get_exposed_port(container.port)}/memory/default"
|
||||
)
|
||||
yield create_engine(url)
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. This is the exact bug class in apache/superset#42899,
|
||||
where Trino emitted OFFSET before LIMIT for paginated queries -- a
|
||||
dialect-compiler bug invisible to mocked tests, only catchable by
|
||||
actually executing the compiled SQL.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
TrinoEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = TrinoEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = TrinoEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,148 +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.
|
||||
"""
|
||||
Tests db_engine_specs.ydb against a real YDB instance, spun up on demand
|
||||
via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
YDB has no dedicated testcontainers module, so this uses a generic
|
||||
DockerContainer against the official `ydbplatform/local-ydb` image, which
|
||||
needs no auth for local/anonymous access -- YDBEngineSpec's own
|
||||
`sqlalchemy_uri_placeholder` ("ydb://{host}:{port}/{database_name}") has
|
||||
no username/password at all.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.ydb import YDBEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.core.container")
|
||||
require_driver("ydb_sqlalchemy")
|
||||
|
||||
from testcontainers.core.container import DockerContainer # noqa: E402
|
||||
from testcontainers.core.wait_strategies import PortWaitStrategy # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
GRPC_PORT = 2136
|
||||
DATABASE = "/local"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
container = DockerContainer("ydbplatform/local-ydb")
|
||||
container.with_exposed_ports(GRPC_PORT)
|
||||
# YDB's gRPC client does endpoint discovery: it asks the server for its
|
||||
# "real" endpoints and reconnects to whatever comes back, rather than
|
||||
# just using the address it was originally given. By default that's
|
||||
# the container's own internal Docker hostname (e.g. "6abbb4bb0ab7"),
|
||||
# which isn't reachable from the host. Binding the same port number on
|
||||
# the host as inside the container, plus advertising "localhost" as
|
||||
# the container's own hostname, makes the discovered endpoint
|
||||
# ("localhost:2136") actually resolve to something reachable.
|
||||
container.with_bind_ports(GRPC_PORT, GRPC_PORT)
|
||||
container.with_kwargs(hostname="localhost")
|
||||
container.with_env("YDB_USE_IN_MEMORY_PDISKS", "true")
|
||||
container.waiting_for(PortWaitStrategy(GRPC_PORT))
|
||||
|
||||
with container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(GRPC_PORT)
|
||||
eng = create_engine(f"yql://{host}:{port}{DATABASE}")
|
||||
|
||||
# The gRPC port opens, and even a bare SELECT succeeds, before YDB's
|
||||
# storage pools are fully initialized -- an actual CREATE TABLE can
|
||||
# still fail with "database doesn't have storage pools at all to
|
||||
# create tablet channels" (confirmed on a real instance). Probe with
|
||||
# metadata.create_all()/drop_all() specifically, the same call the
|
||||
# real tests below make: a raw `text("CREATE TABLE ...")` hits a
|
||||
# separate, unrelated error ("Scheme operations cannot be executed
|
||||
# inside transaction") that create_all()'s own DDL execution path
|
||||
# doesn't, even with AUTOCOMMIT set on a manually-opened connection.
|
||||
probe_metadata = MetaData()
|
||||
SATable("pilot_ready", probe_metadata, Column("id", Integer, primary_key=True))
|
||||
last_error: Exception | None = None
|
||||
for _ in range(30):
|
||||
try:
|
||||
probe_metadata.create_all(eng)
|
||||
probe_metadata.drop_all(eng)
|
||||
break
|
||||
except Exception as ex: # noqa: BLE001 -- retry on any not-ready-yet error
|
||||
last_error = ex
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"YDB never became ready to create and use a table"
|
||||
) from last_error
|
||||
|
||||
yield eng
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
YDBEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = YDBEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = YDBEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -1,119 +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.
|
||||
"""
|
||||
Tests db_engine_specs.yugabytedb against a real YugabyteDB instance, spun
|
||||
up on demand via testcontainers. Run via .github/workflows/testcontainers.yml.
|
||||
|
||||
YugabyteDB's YSQL layer is fully Postgres-wire compatible (postgresql+
|
||||
psycopg2, port 5433), but the image doesn't ship a plain `psql` binary
|
||||
(only its own `ysqlsh`), so testcontainers' PostgresContainer can't be
|
||||
reused directly -- its built-in readiness check execs `psql`, which would
|
||||
fail here. This uses a generic DockerContainer, starting the node via
|
||||
`yugabyted start --background=false` and waiting for yugabyted's own final
|
||||
startup message instead.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
Table as SATable,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from superset.db_engine_specs.yugabytedb import YugabyteDBEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
pytestmark = pytest.mark.testcontainers
|
||||
|
||||
from ._driver import require_driver # noqa: E402
|
||||
|
||||
require_driver("testcontainers.core.container")
|
||||
|
||||
from testcontainers.core.container import DockerContainer # noqa: E402
|
||||
from testcontainers.core.wait_strategies import LogMessageWaitStrategy # noqa: E402
|
||||
|
||||
from ._pagination import ( # noqa: E402
|
||||
assert_paginated_query_returns_correct_rows_in_order,
|
||||
)
|
||||
|
||||
YSQL_PORT = 5433
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> Iterator[Engine]:
|
||||
container = DockerContainer("yugabytedb/yugabyte:latest")
|
||||
container.with_exposed_ports(YSQL_PORT)
|
||||
container.with_command("bin/yugabyted start --background=false")
|
||||
container.waiting_for(
|
||||
LogMessageWaitStrategy(
|
||||
re.compile("Data placement constraint successfully verified")
|
||||
)
|
||||
)
|
||||
|
||||
with container:
|
||||
host = container.get_container_host_ip()
|
||||
port = container.get_exposed_port(YSQL_PORT)
|
||||
# Default single-node credentials/database, per yugabyted's own
|
||||
# documented quickstart defaults -- no env vars needed to set them.
|
||||
yield create_engine(
|
||||
f"postgresql+psycopg2://yugabyte:yugabyte@{host}:{port}/yugabyte"
|
||||
)
|
||||
|
||||
|
||||
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
|
||||
"""
|
||||
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
|
||||
a real instance. Mocked tests cannot catch a dialect compiling this
|
||||
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
|
||||
before LIMIT) -- only real execution can.
|
||||
"""
|
||||
assert_paginated_query_returns_correct_rows_in_order(engine)
|
||||
|
||||
|
||||
def test_get_columns_maps_native_types(engine: Engine) -> None:
|
||||
"""
|
||||
YugabyteDBEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
|
||||
exercises that against actual server-reported column metadata rather
|
||||
than a mocked Inspector.
|
||||
"""
|
||||
metadata = MetaData()
|
||||
SATable(
|
||||
"pilot_types",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("amount", Integer),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = YugabyteDBEngineSpec.get_columns(inspector, Table("pilot_types"))
|
||||
|
||||
by_name = {col["column_name"]: col for col in columns}
|
||||
assert set(by_name) == {"id", "amount"}
|
||||
for col in by_name.values():
|
||||
spec = YugabyteDBEngineSpec.get_column_spec(str(col["type"]))
|
||||
assert spec is not None
|
||||
assert spec.generic_type == GenericDataType.NUMERIC
|
||||
assert isinstance(spec.sqla_type, Integer)
|
||||
@@ -328,6 +328,10 @@ def test_send_chart_response_strips_guest_query_after_timing_projection(
|
||||
"superset.charts.data.api.security_manager.is_guest_user",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
response = api._send_chart_response(result)
|
||||
finally:
|
||||
@@ -339,6 +343,35 @@ def test_send_chart_response_strips_guest_query_after_timing_projection(
|
||||
assert "query" in query_payload
|
||||
|
||||
|
||||
def test_send_chart_response_keeps_guest_query_when_permitted(
|
||||
app: SupersetApp,
|
||||
) -> None:
|
||||
"""
|
||||
A guest whose role carries "can view query on Dashboard" must receive the
|
||||
generated SQL, otherwise "View query" is empty on embedded dashboards.
|
||||
"""
|
||||
query_payload = {"data": [{"col1": 1}], "query": "SELECT 1"}
|
||||
result = _json_execution_result(query_payload)
|
||||
|
||||
api = ChartDataRestApi()
|
||||
with (
|
||||
app.test_request_context("/api/v1/chart/data"),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.is_guest_user",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
return_value=True,
|
||||
) as can_access,
|
||||
):
|
||||
response = api._send_chart_response(result)
|
||||
|
||||
query = json.loads(response.get_data(as_text=True))["result"][0]
|
||||
assert query["query"] == "SELECT 1"
|
||||
can_access.assert_called_once_with("can_view_query", "Dashboard")
|
||||
|
||||
|
||||
def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None:
|
||||
result = _json_execution_result(
|
||||
{
|
||||
@@ -356,6 +389,10 @@ def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None
|
||||
"superset.charts.data.api.security_manager.is_guest_user",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
response = api._send_chart_response(result)
|
||||
|
||||
@@ -364,6 +401,42 @@ def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None
|
||||
assert "stacktrace" not in query
|
||||
|
||||
|
||||
def test_send_chart_response_still_redacts_guest_errors_when_query_permitted(
|
||||
app: SupersetApp,
|
||||
) -> None:
|
||||
"""
|
||||
"can view query on Dashboard" only unlocks the generated SQL; stacktraces
|
||||
and driver errors describe the deployment and stay redacted for guests.
|
||||
"""
|
||||
result = _json_execution_result(
|
||||
{
|
||||
"error": "Table mydb.myschema.mytable was not found",
|
||||
"stacktrace": "Traceback ...",
|
||||
"query": "SELECT 1",
|
||||
},
|
||||
result_type=ChartDataResultType.QUERY,
|
||||
)
|
||||
|
||||
api = ChartDataRestApi()
|
||||
with (
|
||||
app.test_request_context("/api/v1/chart/data"),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.is_guest_user",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
response = api._send_chart_response(result)
|
||||
|
||||
query = json.loads(response.get_data(as_text=True))["result"][0]
|
||||
assert query["query"] == "SELECT 1"
|
||||
assert query["error"] == str(GENERIC_ERROR_MESSAGE)
|
||||
assert "stacktrace" not in query
|
||||
|
||||
|
||||
def test_get_data_response_redacts_guest_query_failure(app: SupersetApp) -> None:
|
||||
command = MagicMock()
|
||||
command.execute.side_effect = ChartDataQueryFailedError(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user