mirror of
https://github.com/apache/superset.git
synced 2026-09-10 17:24:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f08f016d2 | ||
|
|
65fb2ff834 | ||
|
|
d659089c59 | ||
|
|
5e046a857c | ||
|
|
36554237aa | ||
|
|
6f93e1cbb1 | ||
|
|
913259299e | ||
|
|
2351e0ead7 | ||
|
|
8c6f211003 | ||
|
|
0e3d78817f | ||
|
|
f0c8304e24 | ||
|
|
80233aed46 | ||
|
|
6f350428df | ||
|
|
548ccfde44 | ||
|
|
596008203c | ||
|
|
ff46c86df3 | ||
|
|
4e30638024 | ||
|
|
efa9159cc8 | ||
|
|
14668f37bd | ||
|
|
27a2466855 | ||
|
|
e35c6946ec | ||
|
|
12c5bfa0a5 | ||
|
|
0303a234a3 | ||
|
|
09e9927652 | ||
|
|
3f9ea361bb | ||
|
|
f1047140ee | ||
|
|
15e3ab4493 | ||
|
|
755aa2e32f | ||
|
|
17d1ed7353 | ||
|
|
9c1bcb70d0 | ||
|
|
6d7cfac8b2 | ||
|
|
31754a39c9 | ||
|
|
bde48e563e | ||
|
|
0cfd760a36 | ||
|
|
13fe88000a | ||
|
|
cc8ad23d6f | ||
|
|
5c2cbb58bc | ||
|
|
6342c4f338 | ||
|
|
5fa70bdbd8 | ||
|
|
2a876e8b86 | ||
|
|
0533ca9941 | ||
|
|
5f20d2e15a | ||
|
|
6d1d5d64d1 | ||
|
|
06d6b513cd | ||
|
|
afa51125de | ||
|
|
26c07b1ffb | ||
|
|
9ecca47e69 | ||
|
|
6c1df93215 | ||
|
|
06fd0658ae | ||
|
|
a17f38a4e2 | ||
|
|
6ef4794778 | ||
|
|
4cd3ce164d | ||
|
|
8e3e57c1c8 | ||
|
|
61fbfda501 | ||
|
|
9017b9a74f |
@@ -24,7 +24,9 @@ notifications:
|
||||
discussions: notifications@superset.apache.org
|
||||
|
||||
github:
|
||||
del_branch_on_merge: true
|
||||
pull_requests:
|
||||
del_branch_on_merge: true
|
||||
allow_update_branch: true
|
||||
description: "Apache Superset is a Data Visualization and Data Exploration Platform"
|
||||
homepage: https://superset.apache.org/
|
||||
labels:
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
# Scan for vulnerabilities in built container image after pushes to mainline branch.
|
||||
- name: Run Trivy container image vulnerabity scan
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'lean'
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # v0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
|
||||
with:
|
||||
image-ref: ${{ env.IMAGE_TAG }}
|
||||
format: 'sarif'
|
||||
|
||||
@@ -64,11 +64,17 @@ jobs:
|
||||
restore-keys: |
|
||||
pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-
|
||||
|
||||
- name: Get changed files
|
||||
id: changed_files
|
||||
uses: ./.github/actions/file-changes-action
|
||||
with:
|
||||
output: ' '
|
||||
|
||||
- name: pre-commit
|
||||
run: |
|
||||
set +e # Don't exit immediately on failure
|
||||
export SKIP=eslint-frontend,type-checking-frontend
|
||||
pre-commit run --all-files
|
||||
export SKIP=type-checking-frontend
|
||||
pre-commit run --files ${{ steps.changed_files.outputs.files }}
|
||||
PRE_COMMIT_EXIT_CODE=$?
|
||||
git diff --quiet --exit-code
|
||||
GIT_DIFF_EXIT_CODE=$?
|
||||
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
@@ -24,6 +24,14 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Combined datasource list endpoint
|
||||
|
||||
Added a new combined datasource list endpoint at `GET /api/v1/datasource/` to serve datasets and semantic views in one response.
|
||||
|
||||
- The endpoint is available to users with at least one of `can_read` on `Dataset` or `SemanticView`.
|
||||
- Semantic views are included only when the `SEMANTIC_LAYERS` feature flag is enabled.
|
||||
- The endpoint enforces strict `order_column` validation and returns `400` for invalid sort columns.
|
||||
|
||||
### ClickHouse minimum driver version bump
|
||||
|
||||
The minimum required version of `clickhouse-connect` has been raised to `>=0.13.0`. If you are using the ClickHouse connector, please upgrade your `clickhouse-connect` package. The `_mutate_label` workaround that appended hash suffixes to column aliases has also been removed, as it is no longer needed with modern versions of the driver.
|
||||
|
||||
@@ -224,3 +224,52 @@ async def analysis_guide(ctx: Context) -> str:
|
||||
```
|
||||
|
||||
See [MCP Integration](./mcp) for implementation details.
|
||||
|
||||
### Semantic Layers
|
||||
|
||||
Extensions can register custom semantic layer implementations that allow Superset to connect to external data modeling frameworks. Each semantic layer defines how to authenticate, discover semantic views (tables/metrics/dimensions), and execute queries against the external system.
|
||||
|
||||
```python
|
||||
from superset_core.semantic_layers.decorators import semantic_layer
|
||||
from superset_core.semantic_layers.layer import SemanticLayer
|
||||
|
||||
from my_extension.config import MyConfig
|
||||
from my_extension.view import MySemanticView
|
||||
|
||||
|
||||
@semantic_layer(
|
||||
id="my_platform",
|
||||
name="My Data Platform",
|
||||
description="Connect to My Data Platform's semantic layer",
|
||||
)
|
||||
class MySemanticLayer(SemanticLayer[MyConfig, MySemanticView]):
|
||||
configuration_class = MyConfig
|
||||
|
||||
@classmethod
|
||||
def from_configuration(cls, configuration: dict) -> "MySemanticLayer":
|
||||
config = MyConfig.model_validate(configuration)
|
||||
return cls(config)
|
||||
|
||||
@classmethod
|
||||
def get_configuration_schema(cls, configuration=None) -> dict:
|
||||
return MyConfig.model_json_schema()
|
||||
|
||||
@classmethod
|
||||
def get_runtime_schema(cls, configuration=None, runtime_data=None) -> dict:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
def get_semantic_views(self, runtime_configuration: dict) -> set[MySemanticView]:
|
||||
# Return available views from the external platform
|
||||
...
|
||||
|
||||
def get_semantic_view(self, name: str, additional_configuration: dict) -> MySemanticView:
|
||||
# Return a specific view by name
|
||||
...
|
||||
```
|
||||
|
||||
**Note**: The `@semantic_layer` decorator automatically detects context and applies appropriate ID prefixing:
|
||||
|
||||
- **Extension context**: ID prefixed as `extensions.{publisher}.{name}.{id}`
|
||||
- **Host context**: Original ID used as-is
|
||||
|
||||
The decorator registers the class in the semantic layers registry, making it available in the UI for users to create connections. The `configuration_class` should be a Pydantic model that defines the fields needed to connect (credentials, project, database, etc.). Superset uses the model's JSON schema to render the configuration form dynamically.
|
||||
|
||||
+10
-10
@@ -55,20 +55,20 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^8.6.17",
|
||||
"@storybook/addon-docs": "^8.6.18",
|
||||
"@storybook/blocks": "^8.6.15",
|
||||
"@storybook/channels": "^8.6.17",
|
||||
"@storybook/client-logger": "^8.6.17",
|
||||
"@storybook/components": "^8.6.17",
|
||||
"@storybook/core": "^8.6.17",
|
||||
"@storybook/core-events": "^8.6.17",
|
||||
"@storybook/channels": "^8.6.18",
|
||||
"@storybook/client-logger": "^8.6.18",
|
||||
"@storybook/components": "^8.6.18",
|
||||
"@storybook/core": "^8.6.18",
|
||||
"@storybook/core-events": "^8.6.18",
|
||||
"@storybook/csf": "^0.1.13",
|
||||
"@storybook/docs-tools": "^8.6.17",
|
||||
"@storybook/preview-api": "^8.6.17",
|
||||
"@storybook/docs-tools": "^8.6.18",
|
||||
"@storybook/preview-api": "^8.6.18",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.17",
|
||||
"antd": "^6.3.1",
|
||||
"antd": "^6.3.2",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"caniuse-lite": "^1.0.30001775",
|
||||
"docusaurus-plugin-openapi-docs": "^4.6.0",
|
||||
@@ -85,7 +85,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.1.1",
|
||||
"storybook": "^8.6.17",
|
||||
"storybook": "^8.6.18",
|
||||
"swagger-ui-react": "^5.32.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Swizzled from docusaurus-theme-openapi-docs to fix SSG crash.
|
||||
*
|
||||
* The original component calls useTypedSelector (Redux) at the top level,
|
||||
* which fails during static site generation because no Redux store is
|
||||
* available. This version moves the hook into a browser-only child component
|
||||
* so SSG can render the page without a store context.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
interface ServerVariable {
|
||||
default?: string;
|
||||
}
|
||||
|
||||
interface ServerValue {
|
||||
url: string;
|
||||
variables?: Record<string, ServerVariable>;
|
||||
}
|
||||
|
||||
interface StoreState {
|
||||
server: { value: ServerValue | null };
|
||||
}
|
||||
|
||||
function colorForMethod(method: string) {
|
||||
switch (method.toLowerCase()) {
|
||||
case "get":
|
||||
return "primary";
|
||||
case "post":
|
||||
return "success";
|
||||
case "delete":
|
||||
return "danger";
|
||||
case "put":
|
||||
return "info";
|
||||
case "patch":
|
||||
return "warning";
|
||||
case "head":
|
||||
return "secondary";
|
||||
case "event":
|
||||
return "secondary";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
method: string;
|
||||
path: string;
|
||||
context?: "endpoint" | "callback";
|
||||
}
|
||||
|
||||
// Inner component rendered only in the browser, where the Redux store exists.
|
||||
function ServerUrl() {
|
||||
const serverValue = useSelector((state: StoreState) => state.server.value);
|
||||
|
||||
if (serverValue && serverValue.variables) {
|
||||
let serverUrlWithVariables = serverValue.url.replace(/\/$/, "");
|
||||
Object.keys(serverValue.variables).forEach((variable) => {
|
||||
serverUrlWithVariables = serverUrlWithVariables.replace(
|
||||
`{${variable}}`,
|
||||
serverValue.variables?.[variable].default ?? ""
|
||||
);
|
||||
});
|
||||
return <>{serverUrlWithVariables}</>;
|
||||
}
|
||||
|
||||
if (serverValue && serverValue.url) {
|
||||
return <>{serverValue.url}</>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MethodEndpoint({ method, path, context }: Props) {
|
||||
const renderServerUrl = () => {
|
||||
if (context === "callback") {
|
||||
return "";
|
||||
}
|
||||
return <BrowserOnly>{() => <ServerUrl />}</BrowserOnly>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<pre className="openapi__method-endpoint">
|
||||
<span className={"badge badge--" + colorForMethod(method)}>
|
||||
{method === "event" ? "Webhook" : method.toUpperCase()}
|
||||
</span>{" "}
|
||||
{method !== "event" && (
|
||||
<h2 className="openapi__method-endpoint-path">
|
||||
{renderServerUrl()}
|
||||
{`${path.replace(/{([a-z0-9-_]+)}/gi, ":$1")}`}
|
||||
</h2>
|
||||
)}
|
||||
</pre>
|
||||
<div className="openapi__divider" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MethodEndpoint;
|
||||
Vendored
+12
@@ -51,6 +51,12 @@
|
||||
"lifecycle": "development",
|
||||
"description": "Enable Superset extensions for custom functionality without modifying core"
|
||||
},
|
||||
{
|
||||
"name": "GRANULAR_EXPORT_CONTROLS",
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Enable granular export controls (can_export_data, can_export_image, can_copy_clipboard) instead of the single can_csv permission"
|
||||
},
|
||||
{
|
||||
"name": "MATRIXIFY",
|
||||
"default": false,
|
||||
@@ -69,6 +75,12 @@
|
||||
"lifecycle": "development",
|
||||
"description": "Expand nested types in Presto into extra columns/arrays. Experimental, doesn't work with all nested types."
|
||||
},
|
||||
{
|
||||
"name": "SEMANTIC_LAYERS",
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Enable semantic layers and show semantic views alongside datasets"
|
||||
},
|
||||
{
|
||||
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
|
||||
"default": false,
|
||||
|
||||
+89
-108
@@ -195,19 +195,19 @@
|
||||
dependencies:
|
||||
"@ant-design/fast-color" "^3.0.0"
|
||||
|
||||
"@ant-design/cssinjs-utils@^2.1.1":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.1.tgz#c70d86206204e882073a0fe4969a5ddf154c6915"
|
||||
integrity sha512-RKxkj5pGFB+FkPJ5NGhoX3DK3xsv0pMltha7Ei1AnY3tILeq38L7tuhaWDPQI/5nlPxOog44wvqpNyyGcUsNMg==
|
||||
"@ant-design/cssinjs-utils@^2.1.2":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz#a4a57e02dd7e7c3732ab7f1df406df98b5542d12"
|
||||
integrity sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==
|
||||
dependencies:
|
||||
"@ant-design/cssinjs" "^2.1.0"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
"@babel/runtime" "^7.23.2"
|
||||
"@rc-component/util" "^1.4.0"
|
||||
|
||||
"@ant-design/cssinjs@^2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/cssinjs/-/cssinjs-2.1.0.tgz#081394937f86aefe55e35198019d0483f405a484"
|
||||
integrity sha512-eZFrPCnrYrF3XtL7qA4L75P0qA3TtZta8H3Yggy7UYFh8gZgu5bSMNF+v4UVCzGxzYmx8ZvPdgOce0BJ6PsW9g==
|
||||
"@ant-design/cssinjs@^2.1.2":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz#0219e37afdd957248b10da366febae1e4001c952"
|
||||
integrity sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.11.1"
|
||||
"@emotion/hash" "^0.8.0"
|
||||
@@ -2964,12 +2964,12 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/color-picker@~3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/color-picker/-/color-picker-3.1.0.tgz#437586ea2fc27862e7429a754cf85e519e05f461"
|
||||
integrity sha512-o7Vavj7yyfVxFmeynXf0fCHVlC0UTE9al74c6nYuLck+gjuVdQNWSVXR8Efq/mmWFy7891SCOsfaPq6Eqe1s/g==
|
||||
"@rc-component/color-picker@~3.1.1":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/color-picker/-/color-picker-3.1.1.tgz#0a00411457e697cf9320e945762a4b08f71938f9"
|
||||
integrity sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==
|
||||
dependencies:
|
||||
"@ant-design/fast-color" "^3.0.0"
|
||||
"@ant-design/fast-color" "^3.0.1"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
@@ -3009,10 +3009,10 @@
|
||||
"@rc-component/util" "^1.2.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/form@~1.6.2":
|
||||
version "1.6.2"
|
||||
resolved "https://registry.npmjs.org/@rc-component/form/-/form-1.6.2.tgz"
|
||||
integrity sha512-OgIn2RAoaSBqaIgzJf/X6iflIa9LpTozci1lagLBdURDFhGA370v0+T0tXxOi8YShMjTha531sFhwtnrv+EJaQ==
|
||||
"@rc-component/form@~1.7.1":
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/form/-/form-1.7.1.tgz#baf18de01e649415c39e895a2c2fc9c61e1f2e23"
|
||||
integrity sha512-Uhw0FPvJ+Ko4xBxhvziqmqzIuO0YvVBzVyFGNAI9fMCz4r4DfrYK6PRIN6CkFqM0vdAX9sr4JGA1/h/VzpA1cA==
|
||||
dependencies:
|
||||
"@rc-component/async-validator" "^5.1.0"
|
||||
"@rc-component/util" "^1.6.2"
|
||||
@@ -3075,15 +3075,7 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.18.0"
|
||||
|
||||
"@rc-component/motion@^1.0.0", "@rc-component/motion@^1.1.3", "@rc-component/motion@^1.1.4":
|
||||
version "1.1.6"
|
||||
resolved "https://registry.npmjs.org/@rc-component/motion/-/motion-1.1.6.tgz"
|
||||
integrity sha512-aEQobs/YA0kqRvHIPjQvOytdtdRVyhf/uXAal4chBjxDu6odHckExJzjn2D+Ju1aKK6hx3pAs6BXdV9+86xkgQ==
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.2.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/motion@^1.3.1":
|
||||
"@rc-component/motion@^1.0.0", "@rc-component/motion@^1.1.3", "@rc-component/motion@^1.1.4", "@rc-component/motion@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/motion/-/motion-1.3.1.tgz#1e56b06841ee677261251e6e69fedc8d73e65b22"
|
||||
integrity sha512-Wo1mkd0tCcHtvYvpPOmlYJz546z16qlsiwaygmW7NPJpOZOF9GBjhGzdzZSsC2lEJ1IUkWLF4gMHlRA1aSA+Yw==
|
||||
@@ -3184,21 +3176,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/select@~1.6.0":
|
||||
version "1.6.5"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.6.5.tgz#69276239c6ac0884a67597961b0224c4ad0bc4ca"
|
||||
integrity sha512-Cx+/OYEorXlPQ6ZFDro3HbalPZLlJWagvGtl8DGYO4losXM6gw43qbsxWqU1c3XOQVIOUDBlr7dSksSNMj8kXg==
|
||||
dependencies:
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
"@rc-component/virtual-list" "^1.0.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/select@~1.6.12":
|
||||
version "1.6.12"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.6.12.tgz#24312b31aad2a78ce1ec0062b15f56428bddab8f"
|
||||
integrity sha512-jYXAglYdOb54BrpWAcjjhdBP16NyCv/HbEaWFMbEHZQAJVmGHPAtmBqbFuPPuvInAVsIwLbCj4Agag9udOamiQ==
|
||||
"@rc-component/select@~1.6.0", "@rc-component/select@~1.6.14":
|
||||
version "1.6.14"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.6.14.tgz#61028c0abe02d2a909935b5cb586374968196c96"
|
||||
integrity sha512-T1IWeLlSas7Z/igZtPtJ/bweCxMMkXIGKQBtnigK+I/n1AVNjCs+ZdL3Fj42mq3uqm4sd1uzeQLZkdCqR26ADw==
|
||||
dependencies:
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
@@ -3524,53 +3505,53 @@
|
||||
resolved "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@storybook/addon-docs@^8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.6.17.tgz#3b7fdccebb60bcde62241a2ef2c9e493003003d5"
|
||||
integrity sha512-zvcSzoYvaZO4l9NxsviDr5vmuq8GVnH4Ap0v+5sSTq192yevm/iQcRnkWYBD9E/Lg5GBeyE+Ml2vjEOK+EPBEg==
|
||||
"@storybook/addon-docs@^8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.6.18.tgz#1910942ecdff4e5cda6352d22bc483f0c2058f61"
|
||||
integrity sha512-55ADer0yNmmeR928Y3UAv3r4i7bJSd9LwywsQ+lRol/FNe0ZcwLEz31xL+jVsqQFNnDh/imsDIp8aYapGMtfEQ==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/blocks" "8.6.17"
|
||||
"@storybook/csf-plugin" "8.6.17"
|
||||
"@storybook/react-dom-shim" "8.6.17"
|
||||
"@storybook/blocks" "8.6.18"
|
||||
"@storybook/csf-plugin" "8.6.18"
|
||||
"@storybook/react-dom-shim" "8.6.18"
|
||||
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/blocks@8.6.17", "@storybook/blocks@^8.6.15":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.6.17.tgz#153a9e5ce2b1f2e769f2d095208a303266a85823"
|
||||
integrity sha512-zuYHH+0egovMrjWRKwOtgVGbz6KALGowPSWBzQ8deTBu6IXfkz6Ce1hRLJPn5S6/jDqqr9xx8vuAiypnRQ98tA==
|
||||
"@storybook/blocks@8.6.18", "@storybook/blocks@^8.6.15":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.6.18.tgz#d1bf7e9639a86cdf690bea1c53028be725afb1e8"
|
||||
integrity sha512-esZv4msPQ9LxgTb8YUIZhhxVMuI6BPi5bkXtk8c7w7sWuAsqsCe/RnVInn7ooUry2gjnD4hd9+8Eqj0b8oTVoA==
|
||||
dependencies:
|
||||
"@storybook/icons" "^1.2.12"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/channels@^8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-8.6.17.tgz#074930ccfc9ce4a6d798f274819b70d2852f0fbe"
|
||||
integrity sha512-3uwPYVia6MdyeTI2oq46ybpFIZCCjohvzI7zn6NmnRqC8WvZapngLY6OT590eFCrFdgxMszKORUvSsPgtjpnuA==
|
||||
"@storybook/channels@^8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-8.6.18.tgz#21bf4624badc41f343ac7e182ba7a88c5d682bff"
|
||||
integrity sha512-J/xabOEHfMYEWpdm4gR6HD5IdC0e7OsNvgUEspQjcUMhjMwtGm/EaahwNpRUIxO2tgzKj4zHnflGfPCfTd4PgQ==
|
||||
|
||||
"@storybook/client-logger@^8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-8.6.17.tgz#43decc0f507dd7daf9310994fd612b25fc6915a5"
|
||||
integrity sha512-l8vbDNyyR9YfWZzlsupxEeekA/eq4iibBo3gWwr+2G5QfNTGveTQdpgr2m5IL5k+Xjnii22AepmQ4NdjPbJXwA==
|
||||
"@storybook/client-logger@^8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-8.6.18.tgz#21b95c5ecb30475ad5a1fa68c0af603a4199c01b"
|
||||
integrity sha512-l7x3KkumMcTN+R1ozAqEyAkHpNBonIvicYoTgha/3Dh/tKiBYLLum2AEXbiu0TBJ7EEUfi4AG7eOBBfVdfWqvQ==
|
||||
|
||||
"@storybook/components@^8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.6.17.tgz#67c87f4a5b98999c81f17418cbe2396e6dd216f1"
|
||||
integrity sha512-0b8xkkuPCNbM8LTOzyfxuo2KdJCHIfu3+QxWBFllXap0eYNHwVeSxE5KERQ/bk2GDCiRzaUbwH9PeLorxOzJJQ==
|
||||
"@storybook/components@^8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.6.18.tgz#0e5431f9d84cae29a8b8a406c9ad99406bf2ccb4"
|
||||
integrity sha512-55yViiZzPS/cPBuOeW4QGxGqrusjXVyxuknmbYCIwDtFyyvI/CgbjXRHdxNBaIjz+IlftxvBmmSaOqFG5+/dkA==
|
||||
|
||||
"@storybook/core-events@^8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-8.6.17.tgz#e67d6308e61cd6d7be574c40c605eafe7bb04c74"
|
||||
integrity sha512-HiKVE2sSbJF6PVFt2DfJtLef1Mc35cN+sf2f8Ay2ibHy2gY1t3/7W1PhYVGt7UpJNOnVZfsmcE3yqGNojct3mw==
|
||||
"@storybook/core-events@^8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-8.6.18.tgz#aaaf2a544fdb07036a08200692bb88a96d9df651"
|
||||
integrity sha512-eUVwrcppny/ZYyke/SPVZVuco8wxkQ/0K20nlevSiDkgWZSELii5Ju0/l9Ubnopr9dshoFCYbC7q6liTSpok7A==
|
||||
|
||||
"@storybook/core@8.6.17", "@storybook/core@^8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.6.17.tgz#73af480521333e421413ffdda7a992b3c96b1afb"
|
||||
integrity sha512-lndZDYIvUddWk54HmgYwE4h2B0JtWt8ztIRAzHRt6ReZZ9QQbmM5b85Qpa+ng4dyQEKc2JAtYD3Du7RRFcpHlw==
|
||||
"@storybook/core@8.6.18", "@storybook/core@^8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.6.18.tgz#0ddbec8421715b372419ae5dfefef3df5848386c"
|
||||
integrity sha512-dRBP2TnX6fGdS0T2mXBHjkS/3Nlu1ra1huovZVFuM67CYMzrhM/3hX/zru1vWSC5rqY93ZaAhjMciPW4pK5mMQ==
|
||||
dependencies:
|
||||
"@storybook/theming" "8.6.17"
|
||||
"@storybook/theming" "8.6.18"
|
||||
better-opn "^3.0.2"
|
||||
browser-assert "^1.2.1"
|
||||
esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0"
|
||||
@@ -3582,10 +3563,10 @@
|
||||
util "^0.12.5"
|
||||
ws "^8.2.3"
|
||||
|
||||
"@storybook/csf-plugin@8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.6.17.tgz#004e25cd408d98a1514d0bf83e02f270c87a2091"
|
||||
integrity sha512-ouvF/izbKclZxpfnRUkyC5ZVDU7QA0cHhjQnXTDT4F8b0uciQUDw1LosDZy5MXf03BeIDdyBAtzd/ym3wzd+kw==
|
||||
"@storybook/csf-plugin@8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.6.18.tgz#f92cede49c71d4381187884d72e41ee44d324d3b"
|
||||
integrity sha512-x1ioz/L0CwaelCkHci3P31YtvwayN3FBftvwQOPbvRh9qeb4Cpz5IdVDmyvSxxYwXN66uAORNoqgjTi7B4/y5Q==
|
||||
dependencies:
|
||||
unplugin "^1.3.1"
|
||||
|
||||
@@ -3596,30 +3577,30 @@
|
||||
dependencies:
|
||||
type-fest "^2.19.0"
|
||||
|
||||
"@storybook/docs-tools@^8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-8.6.17.tgz#e0beafce8ad36dbadf2e0c3a6bb39ee50ead8c03"
|
||||
integrity sha512-lnGPEecD2nNrByIGhlJOJEi4/3PM+P5DElsFdJ9EhQwO0rwQhTL+4sdBMOXgwsJj4WrQTBXQ1jr/x0UYrl7Qzg==
|
||||
"@storybook/docs-tools@^8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-8.6.18.tgz#ba79b08a41131f97d9c6970c48651552763acbcf"
|
||||
integrity sha512-43ggjDA1ZV0FWjMlNBkKC1VWQ6zDQmSj0WWWqivGQdnBt4dufYQFXnbQeFr9Og+3OjZYmr3KTrLCjDiyCGOgjg==
|
||||
|
||||
"@storybook/icons@^1.2.12":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.npmjs.org/@storybook/icons/-/icons-1.4.0.tgz"
|
||||
integrity sha512-Td73IeJxOyalzvjQL+JXx72jlIYHgs+REaHiREOqfpo3A2AYYG71AUbcv+lg7mEDIweKVCxsMQ0UKo634c8XeA==
|
||||
|
||||
"@storybook/preview-api@^8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.6.17.tgz#92fa66f495c520074c88c3373be73e57f2803a5c"
|
||||
integrity sha512-vpTCTkw11wXerYnlG5Q0y4SbFqG9O6GhR0hlYgCn3Z9kcHlNjK/xuwd3h4CvwNXxRNWZGT8qYYCLn5gSSrX6fA==
|
||||
"@storybook/preview-api@^8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.6.18.tgz#2f5eb75c7587035a07670457c09b67208aa16735"
|
||||
integrity sha512-joXRXh3GdVvzhbfIgmix1xs90p8Q/nja7AhEAC2egn5Pl7SKsIYZUCYI6UdrQANb2myg9P552LKXfPect8llKg==
|
||||
|
||||
"@storybook/react-dom-shim@8.6.17":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.6.17.tgz#68a2e279ac2ce2e37d3f7331a16c5b46cc1c5659"
|
||||
integrity sha512-bHLsR9b/tiwm9lXbN8kp9XlOgkRXeg84UFwXaWBPu3pOO7vRXukk23SQUpLW+HhjKtCJ3xClSi5uMpse5MpkVQ==
|
||||
"@storybook/react-dom-shim@8.6.18":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.6.18.tgz#34bdc010d3c3572fc74fa149f754d185df85044e"
|
||||
integrity sha512-N4xULcAWZQTUv4jy1/d346Tyb4gufuC3UaLCuU/iVSZ1brYF4OW3ANr+096btbMxY8pR/65lmtoqr5CTGwnBvA==
|
||||
|
||||
"@storybook/theming@8.6.17", "@storybook/theming@^8.6.15":
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.6.17.tgz#0175bbc22cdc262d171168af67fce6a5e3d76a7f"
|
||||
integrity sha512-IttFvRqozpuzN5MlQEWGOzUA2rZg86688Dyv1d+bjpYcFHtY1X4XyTCGwv1BPTaTsB959oM8R2yoNYWQkABbBA==
|
||||
"@storybook/theming@8.6.18", "@storybook/theming@^8.6.15":
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.6.18.tgz#18c66263868bfb00a419772b5460a5714c5e1181"
|
||||
integrity sha512-n6OEjEtHupa2PdTwWzRepr7cO8NkDd4rgF6BKLitRbujOspLxzMBEqdphs+QLcuiCIgf33SqmEA64QWnbSMhPw==
|
||||
|
||||
"@superset-ui/core@^0.20.4":
|
||||
version "0.20.4"
|
||||
@@ -5668,14 +5649,14 @@ ansi-styles@^6.1.0:
|
||||
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz"
|
||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||
|
||||
antd@^6.3.1:
|
||||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.3.1.tgz#ea035d7b0f836a20938945d5a0eaef172537d89b"
|
||||
integrity sha512-8pRjvxitZFyrYAtgwml93Km7fCXjw9IeqlmzpIsusRsmO3eWFVrOMum6+0TsGCtR/WrXVnPwfsgrFg3ChzGCeA==
|
||||
antd@^6.3.2:
|
||||
version "6.3.2"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.3.2.tgz#ce1a33783d495fcfc77b58b73156ac6249e4fc0a"
|
||||
integrity sha512-IlMoqaXlq5Bgxi0ANERhAzmDREYyGwr/U7MCVihaUQbE/ZOB3r4ArakUxjA1ULYNDA6K00dawSrB8aalGnZlLA==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.0"
|
||||
"@ant-design/cssinjs-utils" "^2.1.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
"@ant-design/cssinjs-utils" "^2.1.2"
|
||||
"@ant-design/fast-color" "^3.0.1"
|
||||
"@ant-design/icons" "^6.1.0"
|
||||
"@ant-design/react-slick" "~2.0.0"
|
||||
@@ -5683,11 +5664,11 @@ antd@^6.3.1:
|
||||
"@rc-component/cascader" "~1.14.0"
|
||||
"@rc-component/checkbox" "~2.0.0"
|
||||
"@rc-component/collapse" "~1.2.0"
|
||||
"@rc-component/color-picker" "~3.1.0"
|
||||
"@rc-component/color-picker" "~3.1.1"
|
||||
"@rc-component/dialog" "~1.8.4"
|
||||
"@rc-component/drawer" "~1.4.2"
|
||||
"@rc-component/dropdown" "~1.0.2"
|
||||
"@rc-component/form" "~1.6.2"
|
||||
"@rc-component/form" "~1.7.1"
|
||||
"@rc-component/image" "~1.6.0"
|
||||
"@rc-component/input" "~1.1.2"
|
||||
"@rc-component/input-number" "~1.6.2"
|
||||
@@ -5703,7 +5684,7 @@ antd@^6.3.1:
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
"@rc-component/resize-observer" "^1.1.1"
|
||||
"@rc-component/segmented" "~1.3.0"
|
||||
"@rc-component/select" "~1.6.12"
|
||||
"@rc-component/select" "~1.6.14"
|
||||
"@rc-component/slider" "~1.0.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
@@ -14327,12 +14308,12 @@ stop-iteration-iterator@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
internal-slot "^1.1.0"
|
||||
|
||||
storybook@^8.6.17:
|
||||
version "8.6.17"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.6.17.tgz#56299bf9e58622bb834fb100eac89c15f7d0de98"
|
||||
integrity sha512-krR/l680A6qVnkGiK9p8jY0ucX3+kFCs2f4zw+S3w2Cdq8EiM/tFebPcX2V4S3z2UsO0v0dwAJOJNpzbFPdmVg==
|
||||
storybook@^8.6.18:
|
||||
version "8.6.18"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.6.18.tgz#2a635a4b0c99693f43ba21b8eb511c5cc513a807"
|
||||
integrity sha512-p8seiSI6FiVY6P3V0pG+5v7c8pDMehMAFRWEhG5XqIBSQszzOjDnW2rNvm3odoLKfo3V3P6Cs6Hv9ILzymULyQ==
|
||||
dependencies:
|
||||
"@storybook/core" "8.6.17"
|
||||
"@storybook/core" "8.6.18"
|
||||
|
||||
string-convert@^0.2.0:
|
||||
version "0.2.1"
|
||||
|
||||
@@ -285,6 +285,7 @@ module = [
|
||||
"superset.tags.filters",
|
||||
"superset.commands.security.update",
|
||||
"superset.commands.security.create",
|
||||
"superset.semantic_layers.api",
|
||||
]
|
||||
warn_unused_ignores = false
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"flask-appbuilder>=5.0.2,<6",
|
||||
"isodate>=0.7.0",
|
||||
"pyarrow>=16.0.0",
|
||||
"pydantic>=2.8.0",
|
||||
"sqlalchemy>=1.4.0,<2.0",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def build_configuration_schema(
|
||||
config_class: type[BaseModel],
|
||||
configuration: BaseModel | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a JSON schema from a Pydantic configuration class.
|
||||
|
||||
Handles generic boilerplate that any semantic layer with dynamic fields needs:
|
||||
|
||||
- Reorders properties to match model field order (Pydantic sorts alphabetically)
|
||||
- When ``configuration`` is None, sets ``enum: []`` on all ``x-dynamic`` properties
|
||||
so the frontend renders them as empty dropdowns
|
||||
|
||||
Semantic layer implementations call this instead of
|
||||
``model_json_schema()`` directly,
|
||||
then only need to add their own dynamic population logic.
|
||||
"""
|
||||
schema = config_class.model_json_schema()
|
||||
|
||||
# Pydantic sorts properties alphabetically; restore model field order
|
||||
field_order = [
|
||||
field.alias or name for name, field in config_class.model_fields.items()
|
||||
]
|
||||
schema["properties"] = {
|
||||
key: schema["properties"][key]
|
||||
for key in field_order
|
||||
if key in schema["properties"]
|
||||
}
|
||||
|
||||
if configuration is None:
|
||||
for prop_schema in schema["properties"].values():
|
||||
if prop_schema.get("x-dynamic"):
|
||||
prop_schema["enum"] = []
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def check_dependencies(
|
||||
prop_schema: dict[str, Any],
|
||||
configuration: BaseModel,
|
||||
) -> bool:
|
||||
"""
|
||||
Check whether a dynamic property's dependencies are satisfied.
|
||||
|
||||
Reads the ``x-dependsOn`` list from the property schema and returns ``True``
|
||||
when every referenced attribute on ``configuration`` is truthy.
|
||||
"""
|
||||
dependencies = prop_schema.get("x-dependsOn", [])
|
||||
return all(getattr(configuration, dep, None) for dep in dependencies)
|
||||
@@ -0,0 +1,169 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Semantic layer DAO interfaces for superset-core.
|
||||
|
||||
Provides abstract DAO classes for semantic layers and views that define the
|
||||
interface contract. Host implementations replace these with concrete classes
|
||||
backed by SQLAlchemy during initialization.
|
||||
|
||||
Usage:
|
||||
from superset_core.semantic_layers.daos import (
|
||||
AbstractSemanticLayerDAO,
|
||||
AbstractSemanticViewDAO,
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from superset_core.common.daos import BaseDAO
|
||||
from superset_core.semantic_layers.models import SemanticLayerModel, SemanticViewModel
|
||||
|
||||
|
||||
class AbstractSemanticLayerDAO(BaseDAO[SemanticLayerModel]):
|
||||
"""
|
||||
Abstract DAO interface for SemanticLayer.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete DAO providing actual database access.
|
||||
"""
|
||||
|
||||
model_cls: ClassVar[type[Any] | None] = None
|
||||
base_filter = None
|
||||
id_column_name = "uuid"
|
||||
uuid_column_name = "uuid"
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def validate_uniqueness(cls, name: str) -> bool:
|
||||
"""
|
||||
Validate that a semantic layer name is unique.
|
||||
|
||||
:param name: Semantic layer name to validate
|
||||
:return: True if the name is unique, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def validate_update_uniqueness(cls, layer_uuid: str, name: str) -> bool:
|
||||
"""
|
||||
Validate that a semantic layer name is unique for an update operation,
|
||||
excluding the layer being updated.
|
||||
|
||||
:param layer_uuid: UUID of the semantic layer being updated
|
||||
:param name: New name to validate
|
||||
:return: True if the name is unique, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def find_by_name(cls, name: str) -> SemanticLayerModel | None:
|
||||
"""
|
||||
Find a semantic layer by name.
|
||||
|
||||
:param name: Semantic layer name
|
||||
:return: SemanticLayerModel instance or None
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_semantic_views(cls, layer_uuid: str) -> list[SemanticViewModel]:
|
||||
"""
|
||||
Get all semantic views associated with a semantic layer.
|
||||
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:return: List of SemanticViewModel instances
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
|
||||
"""
|
||||
Abstract DAO interface for SemanticView.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete DAO providing actual database access.
|
||||
"""
|
||||
|
||||
model_cls: ClassVar[type[Any] | None] = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
uuid_column_name = "uuid"
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def validate_uniqueness(
|
||||
cls,
|
||||
name: str,
|
||||
layer_uuid: str,
|
||||
configuration: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that a semantic view is unique within a semantic layer.
|
||||
|
||||
Uniqueness is determined by the combination of name, layer UUID, and
|
||||
configuration.
|
||||
|
||||
:param name: View name
|
||||
:param layer_uuid: UUID of the parent semantic layer
|
||||
:param configuration: Configuration dict to compare
|
||||
:return: True if unique, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def validate_update_uniqueness(
|
||||
cls,
|
||||
view_uuid: str,
|
||||
name: str,
|
||||
layer_uuid: str,
|
||||
configuration: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that a semantic view is unique within a semantic layer for an
|
||||
update operation, excluding the view being updated.
|
||||
|
||||
:param view_uuid: UUID of the view being updated
|
||||
:param name: New name to validate
|
||||
:param layer_uuid: UUID of the parent semantic layer
|
||||
:param configuration: Configuration dict to compare
|
||||
:return: True if unique, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def find_by_name(cls, name: str, layer_uuid: str) -> SemanticViewModel | None:
|
||||
"""
|
||||
Find a semantic view by name within a semantic layer.
|
||||
|
||||
:param name: View name
|
||||
:param layer_uuid: UUID of the parent semantic layer
|
||||
:return: SemanticViewModel instance or None
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Semantic layer registration decorator for Superset.
|
||||
|
||||
This module provides a decorator interface to register semantic layer
|
||||
implementations with the host application, enabling automatic discovery
|
||||
by the extensions framework.
|
||||
|
||||
Usage:
|
||||
from superset_core.semantic_layers.decorators import semantic_layer
|
||||
|
||||
@semantic_layer(
|
||||
id="snowflake",
|
||||
name="Snowflake Cortex",
|
||||
description="Snowflake semantic layer via Cortex Analyst",
|
||||
)
|
||||
class SnowflakeSemanticLayer(SemanticLayer[SnowflakeConfig, SnowflakeView]):
|
||||
...
|
||||
|
||||
# Or with minimal arguments:
|
||||
@semantic_layer(id="dbt", name="dbt Semantic Layer")
|
||||
class DbtSemanticLayer(SemanticLayer[DbtConfig, DbtView]):
|
||||
...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
# Type variable for decorated semantic layer classes
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def semantic_layer(
|
||||
id: str,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
) -> Callable[[T], T]:
|
||||
"""
|
||||
Decorator to register a semantic layer implementation.
|
||||
|
||||
Automatically detects extension context and applies appropriate
|
||||
namespacing to prevent ID conflicts between host and extension
|
||||
semantic layers.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
Args:
|
||||
id: Unique semantic layer type identifier (e.g., "snowflake",
|
||||
"dbt"). Used as the key in the semantic layers registry and
|
||||
stored in the ``type`` column of the ``SemanticLayer`` model.
|
||||
name: Human-readable display name (e.g., "Snowflake Cortex").
|
||||
Shown in the UI when listing available semantic layer types.
|
||||
description: Optional description for documentation and UI
|
||||
tooltips.
|
||||
|
||||
Returns:
|
||||
Decorated semantic layer class registered with the host
|
||||
application.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If called before host implementation is
|
||||
initialized.
|
||||
|
||||
Example:
|
||||
from superset_core.semantic_layers.decorators import semantic_layer
|
||||
from superset_core.semantic_layers.layer import SemanticLayer
|
||||
|
||||
@semantic_layer(
|
||||
id="snowflake",
|
||||
name="Snowflake Cortex",
|
||||
description="Connect to Snowflake Cortex Analyst",
|
||||
)
|
||||
class SnowflakeSemanticLayer(
|
||||
SemanticLayer[SnowflakeConfig, SnowflakeView]
|
||||
):
|
||||
...
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Semantic layer decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["semantic_layer"]
|
||||
@@ -0,0 +1,129 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
from superset_core.semantic_layers.view import SemanticView
|
||||
|
||||
ConfigT = TypeVar("ConfigT", bound=BaseModel)
|
||||
SemanticViewT = TypeVar("SemanticViewT", bound="SemanticView")
|
||||
|
||||
|
||||
class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]):
|
||||
"""
|
||||
Abstract base class for semantic layers.
|
||||
"""
|
||||
|
||||
configuration_class: type[BaseModel]
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def from_configuration(
|
||||
cls,
|
||||
configuration: dict[str, Any],
|
||||
) -> SemanticLayer[ConfigT, SemanticViewT]:
|
||||
"""
|
||||
Create a semantic layer from its configuration.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Semantic layers must implement the from_configuration method"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_configuration_schema(
|
||||
cls,
|
||||
configuration: ConfigT | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get the JSON schema for the configuration needed to add the semantic layer.
|
||||
|
||||
A partial configuration `configuration` can be sent to improve the schema,
|
||||
allowing for progressive validation and better UX. For example, a semantic
|
||||
layer might require:
|
||||
|
||||
- auth information
|
||||
- a database
|
||||
|
||||
If the user provides the auth information, a client can send the partial
|
||||
configuration to this method, and the resulting JSON schema would include
|
||||
the list of databases the user has access to, allowing a dropdown to be
|
||||
populated.
|
||||
|
||||
The Snowflake semantic layer has an example implementation of this method, where
|
||||
database and schema names are populated based on the provided connection info.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Semantic layers must implement the get_configuration_schema method"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_runtime_schema(
|
||||
cls,
|
||||
configuration: ConfigT,
|
||||
runtime_data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get the JSON schema for the runtime parameters needed to load semantic views.
|
||||
|
||||
This returns the schema needed to connect to a semantic view given the
|
||||
configuration for the semantic layer. For example, a semantic layer might
|
||||
be configured by:
|
||||
|
||||
- auth information
|
||||
- an optional database
|
||||
|
||||
If the user does not provide a database when creating the semantic layer, the
|
||||
runtime schema would require the database name to be provided before loading any
|
||||
semantic views. This allows users to create semantic layers that connect to a
|
||||
specific database (or project, account, etc.), or that allow users to select it
|
||||
at query time.
|
||||
|
||||
The Snowflake semantic layer has an example implementation of this method, where
|
||||
database and schema names are required if they were not provided in the initial
|
||||
configuration.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Semantic layers must implement the get_runtime_schema method"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_semantic_views(
|
||||
self,
|
||||
runtime_configuration: dict[str, Any],
|
||||
) -> set[SemanticViewT]:
|
||||
"""
|
||||
Get the semantic views available in the semantic layer.
|
||||
|
||||
The runtime configuration can provide information like a given project or
|
||||
schema, used to restrict the semantic views returned.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_semantic_view(
|
||||
self,
|
||||
name: str,
|
||||
additional_configuration: dict[str, Any],
|
||||
) -> SemanticViewT:
|
||||
"""
|
||||
Get a specific semantic view by its name and additional configuration.
|
||||
"""
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Semantic layer model interfaces for superset-core.
|
||||
|
||||
Provides abstract model classes for semantic layers and views that will be
|
||||
replaced by the host implementation's concrete SQLAlchemy models during
|
||||
initialization.
|
||||
|
||||
Usage:
|
||||
from superset_core.semantic_layers.models import (
|
||||
SemanticLayerModel,
|
||||
SemanticViewModel,
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from superset_core.common.models import CoreModel
|
||||
|
||||
|
||||
class SemanticLayerModel(CoreModel):
|
||||
"""
|
||||
Abstract interface for the SemanticLayer database model.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete SQLAlchemy model providing actual persistence.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected column attributes
|
||||
uuid: UUID
|
||||
name: str
|
||||
description: str | None
|
||||
type: str
|
||||
configuration: str
|
||||
configuration_version: int
|
||||
cache_timeout: int | None
|
||||
created_on: datetime | None
|
||||
changed_on: datetime | None
|
||||
|
||||
|
||||
class SemanticViewModel(CoreModel):
|
||||
"""
|
||||
Abstract interface for the SemanticView database model.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with a concrete SQLAlchemy model providing actual persistence.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected column attributes
|
||||
id: int
|
||||
uuid: UUID
|
||||
name: str
|
||||
description: str | None
|
||||
configuration: str
|
||||
configuration_version: int
|
||||
cache_timeout: int | None
|
||||
semantic_layer_uuid: UUID
|
||||
created_on: datetime | None
|
||||
changed_on: datetime | None
|
||||
|
||||
|
||||
__all__ = ["SemanticLayerModel", "SemanticViewModel"]
|
||||
@@ -0,0 +1,209 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
|
||||
import isodate
|
||||
import pyarrow as pa
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Grain:
|
||||
"""
|
||||
Represents a time grain (e.g., day, month, year).
|
||||
|
||||
Attributes:
|
||||
name: Human-readable name of the grain (e.g., "Second")
|
||||
representation: ISO 8601 duration (e.g., "PT1S", "P1D", "P1M")
|
||||
"""
|
||||
|
||||
name: str
|
||||
representation: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
isodate.parse_duration(self.representation)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if isinstance(other, Grain):
|
||||
return self.representation == other.representation
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.representation)
|
||||
|
||||
|
||||
class Grains:
|
||||
"""Pre-defined common grains and factory for custom ones."""
|
||||
|
||||
SECOND = Grain("Second", "PT1S")
|
||||
MINUTE = Grain("Minute", "PT1M")
|
||||
HOUR = Grain("Hour", "PT1H")
|
||||
DAY = Grain("Day", "P1D")
|
||||
WEEK = Grain("Week", "P1W")
|
||||
MONTH = Grain("Month", "P1M")
|
||||
QUARTER = Grain("Quarter", "P3M")
|
||||
YEAR = Grain("Year", "P1Y")
|
||||
|
||||
_REGISTRY: dict[str, Grain] = {
|
||||
"PT1S": SECOND,
|
||||
"PT1M": MINUTE,
|
||||
"PT1H": HOUR,
|
||||
"P1D": DAY,
|
||||
"P1W": WEEK,
|
||||
"P1M": MONTH,
|
||||
"P3M": QUARTER,
|
||||
"P1Y": YEAR,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get(cls, representation: str, name: str | None = None) -> Grain:
|
||||
"""Return a pre-defined grain or create a custom one."""
|
||||
if grain := cls._REGISTRY.get(representation):
|
||||
return grain
|
||||
return Grain(name or representation, representation)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dimension:
|
||||
id: str
|
||||
name: str
|
||||
type: pa.DataType
|
||||
|
||||
definition: str | None = None
|
||||
description: str | None = None
|
||||
grain: Grain | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metric:
|
||||
id: str
|
||||
name: str
|
||||
type: pa.DataType
|
||||
|
||||
definition: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdhocExpression:
|
||||
id: str
|
||||
definition: str
|
||||
|
||||
|
||||
class Operator(str, enum.Enum):
|
||||
EQUALS = "="
|
||||
NOT_EQUALS = "!="
|
||||
GREATER_THAN = ">"
|
||||
LESS_THAN = "<"
|
||||
GREATER_THAN_OR_EQUAL = ">="
|
||||
LESS_THAN_OR_EQUAL = "<="
|
||||
IN = "IN"
|
||||
NOT_IN = "NOT IN"
|
||||
LIKE = "LIKE"
|
||||
NOT_LIKE = "NOT LIKE"
|
||||
IS_NULL = "IS NULL"
|
||||
IS_NOT_NULL = "IS NOT NULL"
|
||||
ADHOC = "ADHOC"
|
||||
|
||||
|
||||
FilterValues = str | int | float | bool | datetime | date | time | timedelta | None
|
||||
|
||||
|
||||
class PredicateType(enum.Enum):
|
||||
WHERE = "WHERE"
|
||||
HAVING = "HAVING"
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Filter:
|
||||
type: PredicateType
|
||||
column: Dimension | Metric | None
|
||||
operator: Operator
|
||||
value: FilterValues | frozenset[FilterValues]
|
||||
|
||||
|
||||
class OrderDirection(enum.Enum):
|
||||
ASC = "ASC"
|
||||
DESC = "DESC"
|
||||
|
||||
|
||||
OrderTuple = tuple[Metric | Dimension | AdhocExpression, OrderDirection]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupLimit:
|
||||
"""
|
||||
Limit query to top/bottom N combinations of specified dimensions.
|
||||
|
||||
The `filters` parameter allows specifying separate filter constraints for the
|
||||
group limit subquery. This is useful when you want to determine the top N groups
|
||||
using different criteria (e.g., a different time range) than the main query.
|
||||
|
||||
For example, you might want to find the top 10 products by sales over the last
|
||||
30 days, but then show daily sales for those products over the last 7 days.
|
||||
"""
|
||||
|
||||
dimensions: list[Dimension]
|
||||
top: int
|
||||
metric: Metric | None
|
||||
direction: OrderDirection = OrderDirection.DESC
|
||||
group_others: bool = False
|
||||
filters: set[Filter] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticRequest:
|
||||
"""
|
||||
Represents a request made to obtain semantic results.
|
||||
|
||||
This could be a SQL query, an HTTP request, etc.
|
||||
"""
|
||||
|
||||
type: str
|
||||
definition: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticResult:
|
||||
"""
|
||||
Represents the results of a semantic query.
|
||||
|
||||
This includes any requests (SQL queries, HTTP requests) that were performed in order
|
||||
to obtain the results, in order to help troubleshooting.
|
||||
"""
|
||||
|
||||
requests: list[SemanticRequest]
|
||||
results: pa.Table
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticQuery:
|
||||
"""
|
||||
Represents a semantic query.
|
||||
"""
|
||||
|
||||
metrics: list[Metric]
|
||||
dimensions: list[Dimension]
|
||||
filters: set[Filter] | None = None
|
||||
order: list[OrderTuple] | None = None
|
||||
limit: int | None = None
|
||||
offset: int | None = None
|
||||
group_limit: GroupLimit | None = None
|
||||
@@ -0,0 +1,108 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from superset_core.semantic_layers.types import (
|
||||
Dimension,
|
||||
Filter,
|
||||
Metric,
|
||||
SemanticQuery,
|
||||
SemanticResult,
|
||||
)
|
||||
|
||||
|
||||
# TODO (betodealmeida): move to the extension JSON
|
||||
class SemanticViewFeature(enum.Enum):
|
||||
"""
|
||||
Custom features supported by semantic layers.
|
||||
"""
|
||||
|
||||
ADHOC_EXPRESSIONS_IN_ORDERBY = "ADHOC_EXPRESSIONS_IN_ORDERBY"
|
||||
GROUP_LIMIT = "GROUP_LIMIT"
|
||||
GROUP_OTHERS = "GROUP_OTHERS"
|
||||
|
||||
|
||||
class SemanticView(ABC):
|
||||
"""
|
||||
Abstract base class for semantic views.
|
||||
"""
|
||||
|
||||
features: frozenset[SemanticViewFeature]
|
||||
|
||||
@abstractmethod
|
||||
def uid(self) -> str:
|
||||
"""
|
||||
Returns a unique identifier for the semantic view.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_dimensions(self) -> set[Dimension]:
|
||||
"""
|
||||
Get the dimensions defined in the semantic view.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_metrics(self) -> set[Metric]:
|
||||
"""
|
||||
Get the metrics defined in the semantic view.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_values(
|
||||
self,
|
||||
dimension: Dimension,
|
||||
filters: set[Filter] | None = None,
|
||||
) -> SemanticResult:
|
||||
"""
|
||||
Return distinct values for a dimension.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_table(self, query: SemanticQuery) -> SemanticResult:
|
||||
"""
|
||||
Execute a semantic query and return the results.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_row_count(self, query: SemanticQuery) -> SemanticResult:
|
||||
"""
|
||||
Execute a query and return the number of rows the result would have.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_compatible_metrics(
|
||||
self,
|
||||
selected_metrics: set[Metric],
|
||||
selected_dimensions: set[Dimension],
|
||||
) -> set[Metric]:
|
||||
"""
|
||||
Return metrics compatible with the selected dimensions.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_compatible_dimensions(
|
||||
self,
|
||||
selected_metrics: set[Metric],
|
||||
selected_dimensions: set[Dimension],
|
||||
) -> set[Dimension]:
|
||||
"""
|
||||
Return dimensions compatible with the selected metrics.
|
||||
"""
|
||||
@@ -69,7 +69,7 @@ module.exports = {
|
||||
],
|
||||
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect)',
|
||||
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
@@ -237,8 +237,7 @@
|
||||
"jsx-a11y/no-noninteractive-tabindex": "error",
|
||||
"jsx-a11y/no-redundant-roles": "error",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
// TODO: Fix missing aria-selected on tab roles
|
||||
"jsx-a11y/role-has-required-aria-props": "warn",
|
||||
"jsx-a11y/role-has-required-aria-props": "error",
|
||||
"jsx-a11y/role-supports-aria-props": "error",
|
||||
"jsx-a11y/scope": "error",
|
||||
"jsx-a11y/tabindex-no-positive": "error",
|
||||
|
||||
Generated
+1098
-882
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,14 @@
|
||||
"@luma.gl/gltf": "~9.2.5",
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.7.0",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/antd": "^5.24.13",
|
||||
"@rjsf/core": "^5.24.13",
|
||||
"@rjsf/utils": "^5.24.3",
|
||||
"@rjsf/validator-ajv8": "^5.24.13",
|
||||
@@ -197,7 +204,7 @@
|
||||
"react": "^17.0.2",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-diff-viewer-continued": "^4.2.0",
|
||||
"react-dnd": "^11.1.3",
|
||||
"react-dnd-html5-backend": "^11.1.3",
|
||||
"react-dom": "^17.0.2",
|
||||
@@ -325,7 +332,7 @@
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.2",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.0",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -335,20 +342,20 @@
|
||||
"html-webpack-plugin": "^5.6.6",
|
||||
"http-server": "^14.1.1",
|
||||
"imports-loader": "^5.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest": "^30.3.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-html-reporter": "^4.3.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^28.1.0",
|
||||
"lerna": "^8.2.3",
|
||||
"lightningcss": "^1.31.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.0",
|
||||
"open-cli": "^8.0.0",
|
||||
"oxlint": "^1.51.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-packagejson": "^3.0.0",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"fs-extra": "^11.3.3",
|
||||
"jest": "^30.2.0",
|
||||
"jest": "^30.3.0",
|
||||
"yeoman-test": "^11.3.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+19
-1
@@ -23,7 +23,7 @@ import { Label } from '..';
|
||||
|
||||
// Define the prop types for DatasetTypeLabel
|
||||
interface DatasetTypeLabelProps {
|
||||
datasetType: 'physical' | 'virtual'; // Accepts only 'physical' or 'virtual'
|
||||
datasetType: 'physical' | 'virtual' | 'semantic_view';
|
||||
}
|
||||
|
||||
const SIZE = 's'; // Define the size as a constant
|
||||
@@ -32,6 +32,24 @@ export const DatasetTypeLabel: React.FC<DatasetTypeLabelProps> = ({
|
||||
datasetType,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (datasetType === 'semantic_view') {
|
||||
return (
|
||||
<Label
|
||||
icon={
|
||||
<Icons.ApartmentOutlined
|
||||
iconSize={SIZE}
|
||||
iconColor={theme.colorInfo}
|
||||
/>
|
||||
}
|
||||
type="info"
|
||||
style={{ color: theme.colorInfo }}
|
||||
>
|
||||
{t('Semantic')}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
const label: string =
|
||||
datasetType === 'physical' ? t('Physical') : t('Virtual');
|
||||
const icon =
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
|
||||
import { DatasourceType } from './types/Datasource';
|
||||
|
||||
const DATASOURCE_TYPE_MAP: Record<string, DatasourceType> = {
|
||||
table: DatasourceType.Table,
|
||||
query: DatasourceType.Query,
|
||||
dataset: DatasourceType.Dataset,
|
||||
sl_table: DatasourceType.SlTable,
|
||||
saved_query: DatasourceType.SavedQuery,
|
||||
semantic_view: DatasourceType.SemanticView,
|
||||
};
|
||||
|
||||
export default class DatasourceKey {
|
||||
readonly id: number;
|
||||
|
||||
@@ -27,8 +36,7 @@ export default class DatasourceKey {
|
||||
constructor(key: string) {
|
||||
const [idStr, typeStr] = key.split('__');
|
||||
this.id = parseInt(idStr, 10);
|
||||
this.type = DatasourceType.Table; // default to SqlaTable model
|
||||
this.type = typeStr === 'query' ? DatasourceType.Query : this.type;
|
||||
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
|
||||
@@ -26,6 +26,7 @@ export enum DatasourceType {
|
||||
Dataset = 'dataset',
|
||||
SlTable = 'sl_table',
|
||||
SavedQuery = 'saved_query',
|
||||
SemanticView = 'semantic_view',
|
||||
}
|
||||
|
||||
export interface Currency {
|
||||
|
||||
@@ -56,9 +56,11 @@ export enum FeatureFlag {
|
||||
FilterBarClosedByDefault = 'FILTERBAR_CLOSED_BY_DEFAULT',
|
||||
GlobalAsyncQueries = 'GLOBAL_ASYNC_QUERIES',
|
||||
GlobalTaskFramework = 'GLOBAL_TASK_FRAMEWORK',
|
||||
GranularExportControls = 'GRANULAR_EXPORT_CONTROLS',
|
||||
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
|
||||
Matrixify = 'MATRIXIFY',
|
||||
ScheduledQueries = 'SCHEDULED_QUERIES',
|
||||
SemanticLayers = 'SEMANTIC_LAYERS',
|
||||
SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE',
|
||||
SqlValidatorsByEngine = 'SQL_VALIDATORS_BY_ENGINE',
|
||||
SshTunneling = 'SSH_TUNNELING',
|
||||
|
||||
@@ -28,10 +28,11 @@ test('DEFAULT_METRICS', () => {
|
||||
});
|
||||
|
||||
test('DatasourceType', () => {
|
||||
expect(Object.keys(DatasourceType).length).toBe(5);
|
||||
expect(Object.keys(DatasourceType).length).toBe(6);
|
||||
expect(DatasourceType.Table).toBe('table');
|
||||
expect(DatasourceType.Query).toBe('query');
|
||||
expect(DatasourceType.Dataset).toBe('dataset');
|
||||
expect(DatasourceType.SlTable).toBe('sl_table');
|
||||
expect(DatasourceType.SavedQuery).toBe('saved_query');
|
||||
expect(DatasourceType.SemanticView).toBe('semantic_view');
|
||||
});
|
||||
|
||||
@@ -46,6 +46,6 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"jest": "^30.2.0"
|
||||
"jest": "^30.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,6 @@
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.2.0"
|
||||
"jest": "^30.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export interface ResultSetProps {
|
||||
const ResultContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
row-gap: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ const TABS_KEYS = {
|
||||
const StyledPane = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.ant-tabs .ant-tabs-content-holder {
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -79,6 +80,7 @@ const StyledPane = styled.div`
|
||||
${({ theme }) => theme.sizeUnit * 2}px;
|
||||
}
|
||||
.ant-tabs-tabpane {
|
||||
padding-top: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
.scrollable {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
+1
@@ -168,6 +168,7 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, Props>(
|
||||
key={`filter-title-tab-${id}`}
|
||||
onClick={() => onChange(id)}
|
||||
className={classNames.join(' ')}
|
||||
aria-selected={isActive}
|
||||
>
|
||||
<div css={{ display: 'flex', width: '100%', alignItems: 'center' }}>
|
||||
<div
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ const ItemTitleContainer = forwardRef<HTMLDivElement, Props>(
|
||||
key={`item-title-tab-${id}`}
|
||||
onClick={() => onChange(id)}
|
||||
className={classNames.join(' ')}
|
||||
aria-selected={isActive}
|
||||
>
|
||||
<div css={{ display: 'flex', width: '100%', alignItems: 'center' }}>
|
||||
<div
|
||||
|
||||
@@ -29,9 +29,11 @@ import { Dispatch } from 'redux';
|
||||
import {
|
||||
Currency,
|
||||
ensureIsArray,
|
||||
FeatureFlag,
|
||||
getCategoricalSchemeRegistry,
|
||||
getColumnLabel,
|
||||
getSequentialSchemeRegistry,
|
||||
isFeatureEnabled,
|
||||
NO_TIME_RANGE,
|
||||
QueryFormColumn,
|
||||
VizType,
|
||||
@@ -142,11 +144,20 @@ export const hydrateExplore =
|
||||
if (colorSchemeKey) verifyColorScheme(ColorSchemeType.CATEGORICAL);
|
||||
if (linearColorSchemeKey) verifyColorScheme(ColorSchemeType.SEQUENTIAL);
|
||||
|
||||
const granularExport = isFeatureEnabled(FeatureFlag.GranularExportControls);
|
||||
const exploreState = {
|
||||
// note this will add `form_data` to state,
|
||||
// which will be manipulable by future reducers.
|
||||
can_add: findPermission('can_write', 'Chart', user?.roles),
|
||||
can_download: findPermission('can_csv', 'Superset', user?.roles),
|
||||
can_download: granularExport
|
||||
? findPermission('can_export_data', 'Superset', user?.roles)
|
||||
: findPermission('can_csv', 'Superset', user?.roles),
|
||||
can_export_image: granularExport
|
||||
? findPermission('can_export_image', 'Superset', user?.roles)
|
||||
: true,
|
||||
can_copy_clipboard: granularExport
|
||||
? findPermission('can_copy_clipboard', 'Superset', user?.roles)
|
||||
: true,
|
||||
can_overwrite: ensureIsArray(slice?.owners).includes(
|
||||
user?.userId as number,
|
||||
),
|
||||
|
||||
@@ -151,11 +151,8 @@ export const getSlicePayload = async (
|
||||
const [id, typeString] = formData.datasource.split('__');
|
||||
datasourceId = parseInt(id, 10);
|
||||
|
||||
const formattedTypeString =
|
||||
typeString.charAt(0).toUpperCase() + typeString.slice(1);
|
||||
if (formattedTypeString in DatasourceType) {
|
||||
datasourceType =
|
||||
DatasourceType[formattedTypeString as keyof typeof DatasourceType];
|
||||
if (Object.values(DatasourceType).includes(typeString as DatasourceType)) {
|
||||
datasourceType = typeString as DatasourceType;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ import { SaveActionType } from 'src/explore/types';
|
||||
export interface ExploreState {
|
||||
can_add?: boolean;
|
||||
can_download?: boolean;
|
||||
can_export_image?: boolean;
|
||||
can_copy_clipboard?: boolean;
|
||||
can_overwrite?: boolean;
|
||||
isDatasourceMetaLoading?: boolean;
|
||||
isDatasourcesLoading?: boolean;
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface ExplorePageState {
|
||||
explore: {
|
||||
can_add: boolean;
|
||||
can_download: boolean;
|
||||
can_export_image: boolean;
|
||||
can_copy_clipboard: boolean;
|
||||
can_overwrite: boolean;
|
||||
isDatasourceMetaLoading: boolean;
|
||||
isStarred: boolean;
|
||||
|
||||
@@ -150,6 +150,7 @@ export interface ButtonProps {
|
||||
buttonStyle: 'primary' | 'secondary' | 'dashed' | 'link' | 'tertiary';
|
||||
loading?: boolean;
|
||||
icon?: IconType;
|
||||
component?: ReactNode;
|
||||
}
|
||||
|
||||
export interface SubMenuProps {
|
||||
@@ -300,18 +301,22 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
|
||||
</SubMenu>
|
||||
))}
|
||||
</Menu>
|
||||
{props.buttons?.map((btn, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
buttonStyle={btn.buttonStyle}
|
||||
icon={btn.icon}
|
||||
onClick={btn.onClick}
|
||||
data-test={btn['data-test']}
|
||||
loading={btn.loading ?? false}
|
||||
>
|
||||
{btn.name}
|
||||
</Button>
|
||||
))}
|
||||
{props.buttons?.map((btn, i) =>
|
||||
btn.component ? (
|
||||
<span key={i}>{btn.component}</span>
|
||||
) : (
|
||||
<Button
|
||||
key={i}
|
||||
buttonStyle={btn.buttonStyle}
|
||||
icon={btn.icon}
|
||||
onClick={btn.onClick}
|
||||
data-test={btn['data-test']}
|
||||
loading={btn.loading ?? false}
|
||||
>
|
||||
{btn.name}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
{props.children}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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 { SupersetClient } from '@superset-ui/core';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
|
||||
import SemanticLayerModal from './SemanticLayerModal';
|
||||
|
||||
let mockJsonFormsChangeTriggered = false;
|
||||
|
||||
jest.mock('@jsonforms/react', () => ({
|
||||
...jest.requireActual('@jsonforms/react'),
|
||||
JsonForms: ({ onChange }: { onChange: (value: unknown) => void }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
if (!mockJsonFormsChangeTriggered) {
|
||||
mockJsonFormsChangeTriggered = true;
|
||||
onChange({
|
||||
data: { warehouse: 'wh1' },
|
||||
errors: [],
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
SupersetClient: {
|
||||
...jest.requireActual('@superset-ui/core').SupersetClient,
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
},
|
||||
getClientErrorObject: jest.fn(() => Promise.resolve({ error: '' })),
|
||||
}));
|
||||
|
||||
const mockedGet = SupersetClient.get as jest.Mock;
|
||||
const mockedPost = SupersetClient.post as jest.Mock;
|
||||
|
||||
const props = {
|
||||
show: true,
|
||||
onHide: jest.fn(),
|
||||
addDangerToast: jest.fn(),
|
||||
addSuccessToast: jest.fn(),
|
||||
semanticLayerUuid: '11111111-1111-1111-1111-111111111111',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockJsonFormsChangeTriggered = false;
|
||||
jest.useFakeTimers();
|
||||
mockedGet.mockReset();
|
||||
mockedPost.mockReset();
|
||||
|
||||
mockedGet
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
result: [{ id: 'snowflake', name: 'Snowflake', description: '' }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
result: {
|
||||
name: 'Layer 1',
|
||||
type: 'snowflake',
|
||||
configuration: { warehouse: 'wh0' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedPost.mockResolvedValue({
|
||||
json: {
|
||||
result: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
'x-dynamic': true,
|
||||
'x-dependsOn': ['warehouse'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('posts configuration schema refresh after debounce', async () => {
|
||||
render(<SemanticLayerModal {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedPost).toHaveBeenCalledWith({
|
||||
endpoint: '/api/v1/semantic_layer/schema/configuration',
|
||||
jsonPayload: { type: 'snowflake' },
|
||||
});
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(501);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedPost).toHaveBeenCalledWith({
|
||||
endpoint: '/api/v1/semantic_layer/schema/configuration',
|
||||
jsonPayload: {
|
||||
type: 'snowflake',
|
||||
configuration: { warehouse: 'wh1' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* 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 { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
|
||||
import { Input, Select, Button } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { JsonForms, withJsonFormsControlProps } from '@jsonforms/react';
|
||||
import type {
|
||||
JsonSchema,
|
||||
UISchemaElement,
|
||||
ControlProps,
|
||||
} from '@jsonforms/core';
|
||||
import {
|
||||
rankWith,
|
||||
and,
|
||||
isStringControl,
|
||||
formatIs,
|
||||
schemaMatches,
|
||||
} from '@jsonforms/core';
|
||||
import {
|
||||
rendererRegistryEntries,
|
||||
cellRegistryEntries,
|
||||
TextControl,
|
||||
} from '@great-expectations/jsonforms-antd-renderers';
|
||||
import type { ErrorObject } from 'ajv';
|
||||
import {
|
||||
StandardModal,
|
||||
ModalFormField,
|
||||
MODAL_STANDARD_WIDTH,
|
||||
MODAL_MEDIUM_WIDTH,
|
||||
} from 'src/components/Modal';
|
||||
|
||||
/**
|
||||
* Custom renderer that renders `Input.Password` for fields with
|
||||
* `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
|
||||
*/
|
||||
function PasswordControl(props: ControlProps) {
|
||||
const uischema = {
|
||||
...props.uischema,
|
||||
options: { ...props.uischema.options, type: 'password' },
|
||||
};
|
||||
return TextControl({ ...props, uischema });
|
||||
}
|
||||
const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
|
||||
const passwordEntry = {
|
||||
tester: rankWith(3, and(isStringControl, formatIs('password'))),
|
||||
renderer: PasswordRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderer for `const` properties (e.g. Pydantic discriminator fields).
|
||||
* Renders nothing visually but ensures the const value is set in form data,
|
||||
* so discriminated unions resolve correctly on the backend.
|
||||
*/
|
||||
function ConstControl({ data, handleChange, path, schema }: ControlProps) {
|
||||
const constValue = (schema as Record<string, unknown>).const;
|
||||
useEffect(() => {
|
||||
if (constValue !== undefined && data !== constValue) {
|
||||
handleChange(path, constValue);
|
||||
}
|
||||
}, [constValue, data, handleChange, path]);
|
||||
return null;
|
||||
}
|
||||
const ConstRenderer = withJsonFormsControlProps(ConstControl);
|
||||
const constEntry = {
|
||||
tester: rankWith(
|
||||
10,
|
||||
schemaMatches(s => s !== undefined && 'const' in s),
|
||||
),
|
||||
renderer: ConstRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether all dependency values are filled (non-empty).
|
||||
* Handles nested objects (like auth) by checking they have at least one key.
|
||||
*/
|
||||
function areDependenciesSatisfied(
|
||||
dependencies: string[],
|
||||
data: Record<string, unknown>,
|
||||
): boolean {
|
||||
return dependencies.every(dep => {
|
||||
const value = data[dep];
|
||||
if (value === null || value === undefined || value === '') return false;
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for fields marked `x-dynamic` in the JSON Schema.
|
||||
* Shows a loading spinner inside the input while the schema is being
|
||||
* refreshed with dynamic values from the backend.
|
||||
*/
|
||||
function DynamicFieldControl(props: ControlProps) {
|
||||
const { refreshingSchema, formData: cfgData } = props.config ?? {};
|
||||
const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
|
||||
const refreshing =
|
||||
refreshingSchema &&
|
||||
Array.isArray(deps) &&
|
||||
areDependenciesSatisfied(
|
||||
deps as string[],
|
||||
(cfgData as Record<string, unknown>) ?? {},
|
||||
);
|
||||
|
||||
if (!refreshing) {
|
||||
return TextControl(props);
|
||||
}
|
||||
|
||||
const uischema = {
|
||||
...props.uischema,
|
||||
options: {
|
||||
...props.uischema.options,
|
||||
placeholderText: t('Loading...'),
|
||||
inputProps: { suffix: <Icons.LoadingOutlined iconSize="s" /> },
|
||||
},
|
||||
};
|
||||
return TextControl({ ...props, uischema, enabled: false });
|
||||
}
|
||||
const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
|
||||
const dynamicFieldEntry = {
|
||||
tester: rankWith(
|
||||
3,
|
||||
and(
|
||||
isStringControl,
|
||||
schemaMatches(
|
||||
s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
|
||||
),
|
||||
),
|
||||
),
|
||||
renderer: DynamicFieldRenderer,
|
||||
};
|
||||
|
||||
const renderers = [
|
||||
...rendererRegistryEntries,
|
||||
passwordEntry,
|
||||
constEntry,
|
||||
dynamicFieldEntry,
|
||||
];
|
||||
|
||||
type Step = 'type' | 'config';
|
||||
type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow';
|
||||
|
||||
const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Removes empty `enum` arrays from schema properties. The JSON Schema spec
|
||||
* requires `enum` to have at least one item, and AJV rejects empty arrays.
|
||||
* Fields with empty enums are rendered as plain text inputs instead.
|
||||
*/
|
||||
function sanitizeSchema(schema: JsonSchema): JsonSchema {
|
||||
if (!schema.properties) return schema;
|
||||
const properties: Record<string, JsonSchema> = {};
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
if (
|
||||
typeof prop === 'object' &&
|
||||
prop !== null &&
|
||||
'enum' in prop &&
|
||||
Array.isArray(prop.enum) &&
|
||||
prop.enum.length === 0
|
||||
) {
|
||||
const { enum: _empty, ...rest } = prop;
|
||||
properties[key] = rest;
|
||||
} else {
|
||||
properties[key] = prop as JsonSchema;
|
||||
}
|
||||
}
|
||||
return { ...schema, properties } as JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a JSON Forms UI schema from a JSON Schema, using the first
|
||||
* `examples` entry as placeholder text for each string property.
|
||||
*/
|
||||
function buildUiSchema(schema: JsonSchema): UISchemaElement | undefined {
|
||||
if (!schema.properties) return undefined;
|
||||
|
||||
// Use explicit property order from backend if available,
|
||||
// otherwise fall back to the JSON object key order
|
||||
const propertyOrder: string[] =
|
||||
((schema as Record<string, unknown>)['x-propertyOrder'] as string[]) ??
|
||||
Object.keys(schema.properties);
|
||||
|
||||
const elements = propertyOrder
|
||||
.filter(key => key in (schema.properties ?? {}))
|
||||
.map(key => {
|
||||
const prop = schema.properties![key];
|
||||
const control: Record<string, unknown> = {
|
||||
type: 'Control',
|
||||
scope: `#/properties/${key}`,
|
||||
};
|
||||
if (typeof prop === 'object' && prop !== null) {
|
||||
const options: Record<string, unknown> = {};
|
||||
if (
|
||||
'examples' in prop &&
|
||||
Array.isArray(prop.examples) &&
|
||||
prop.examples.length > 0
|
||||
) {
|
||||
options.placeholderText = String(prop.examples[0]);
|
||||
}
|
||||
if ('description' in prop && typeof prop.description === 'string') {
|
||||
options.tooltip = prop.description;
|
||||
}
|
||||
if (Object.keys(options).length > 0) {
|
||||
control.options = options;
|
||||
}
|
||||
}
|
||||
return control;
|
||||
});
|
||||
return { type: 'VerticalLayout', elements } as UISchemaElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts dynamic field dependency mappings from the schema.
|
||||
* Returns a map of field name → list of dependency field names.
|
||||
*/
|
||||
function getDynamicDependencies(schema: JsonSchema): Record<string, string[]> {
|
||||
const deps: Record<string, string[]> = {};
|
||||
if (!schema.properties) return deps;
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
if (
|
||||
typeof prop === 'object' &&
|
||||
prop !== null &&
|
||||
'x-dynamic' in prop &&
|
||||
'x-dependsOn' in prop &&
|
||||
Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
|
||||
) {
|
||||
deps[key] = (prop as Record<string, unknown>)['x-dependsOn'] as string[];
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the dependency values for a set of fields into a stable string
|
||||
* for comparison, so we only re-fetch when dependency values actually change.
|
||||
*/
|
||||
function serializeDependencyValues(
|
||||
dynamicDeps: Record<string, string[]>,
|
||||
data: Record<string, unknown>,
|
||||
): string {
|
||||
const allDepKeys = new Set<string>();
|
||||
for (const deps of Object.values(dynamicDeps)) {
|
||||
for (const dep of deps) {
|
||||
allDepKeys.add(dep);
|
||||
}
|
||||
}
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const key of [...allDepKeys].sort()) {
|
||||
snapshot[key] = data[key];
|
||||
}
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
interface SemanticLayerType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface SemanticLayerModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
semanticLayerUuid?: string;
|
||||
}
|
||||
|
||||
export default function SemanticLayerModal({
|
||||
show,
|
||||
onHide,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
semanticLayerUuid,
|
||||
}: SemanticLayerModalProps) {
|
||||
const isEditMode = !!semanticLayerUuid;
|
||||
const [step, setStep] = useState<Step>('type');
|
||||
const [name, setName] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [types, setTypes] = useState<SemanticLayerType[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [configSchema, setConfigSchema] = useState<JsonSchema | null>(null);
|
||||
const [uiSchema, setUiSchema] = useState<UISchemaElement | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasErrors, setHasErrors] = useState(true);
|
||||
const [refreshingSchema, setRefreshingSchema] = useState(false);
|
||||
const [validationMode, setValidationMode] =
|
||||
useState<ValidationMode>('ValidateAndHide');
|
||||
const errorsRef = useRef<ErrorObject[]>([]);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastDepSnapshotRef = useRef<string>('');
|
||||
const dynamicDepsRef = useRef<Record<string, string[]>>({});
|
||||
|
||||
const fetchTypes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: '/api/v1/semantic_layer/types',
|
||||
});
|
||||
setTypes(json.result ?? []);
|
||||
} catch {
|
||||
addDangerToast(
|
||||
t('An error occurred while fetching semantic layer types'),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addDangerToast]);
|
||||
|
||||
const applySchema = useCallback((rawSchema: JsonSchema) => {
|
||||
const schema = sanitizeSchema(rawSchema);
|
||||
setConfigSchema(schema);
|
||||
setUiSchema(buildUiSchema(schema));
|
||||
dynamicDepsRef.current = getDynamicDependencies(rawSchema);
|
||||
}, []);
|
||||
|
||||
const fetchConfigSchema = useCallback(
|
||||
async (type: string, configuration?: Record<string, unknown>) => {
|
||||
const isInitialFetch = !configuration;
|
||||
if (isInitialFetch) setLoading(true);
|
||||
else setRefreshingSchema(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.post({
|
||||
endpoint: '/api/v1/semantic_layer/schema/configuration',
|
||||
jsonPayload: { type, configuration },
|
||||
});
|
||||
applySchema(json.result);
|
||||
if (json.warning) {
|
||||
addDangerToast(String(json.warning));
|
||||
}
|
||||
if (isInitialFetch) setStep('config');
|
||||
} catch (error) {
|
||||
const clientError = await getClientErrorObject(error);
|
||||
if (isInitialFetch) {
|
||||
addDangerToast(
|
||||
clientError.error ||
|
||||
t('An error occurred while fetching the configuration schema'),
|
||||
);
|
||||
} else {
|
||||
addDangerToast(
|
||||
clientError.error ||
|
||||
t('An error occurred while refreshing the configuration schema'),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isInitialFetch) setLoading(false);
|
||||
else setRefreshingSchema(false);
|
||||
}
|
||||
},
|
||||
[addDangerToast, applySchema],
|
||||
);
|
||||
|
||||
const fetchExistingLayer = useCallback(
|
||||
async (uuid: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/semantic_layer/${uuid}`,
|
||||
});
|
||||
const layer = json.result;
|
||||
setName(layer.name ?? '');
|
||||
setSelectedType(layer.type);
|
||||
setFormData(layer.configuration ?? {});
|
||||
setHasErrors(false);
|
||||
// Fetch base schema (no configuration → no Snowflake connection) to
|
||||
// show the form immediately. The existing maybeRefreshSchema machinery
|
||||
// will trigger an enriched fetch in the background once deps are
|
||||
// satisfied, and DynamicFieldControl will show per-field spinners.
|
||||
const { json: schemaJson } = await SupersetClient.post({
|
||||
endpoint: '/api/v1/semantic_layer/schema/configuration',
|
||||
jsonPayload: { type: layer.type },
|
||||
});
|
||||
applySchema(schemaJson.result);
|
||||
setStep('config');
|
||||
} catch (error) {
|
||||
const clientError = await getClientErrorObject(error);
|
||||
addDangerToast(
|
||||
clientError.error ||
|
||||
t('An error occurred while fetching the semantic layer'),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[addDangerToast, applySchema],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
if (isEditMode && semanticLayerUuid) {
|
||||
fetchTypes();
|
||||
fetchExistingLayer(semanticLayerUuid);
|
||||
} else {
|
||||
fetchTypes();
|
||||
}
|
||||
} else {
|
||||
setStep('type');
|
||||
setName('');
|
||||
setSelectedType(null);
|
||||
setTypes([]);
|
||||
setConfigSchema(null);
|
||||
setUiSchema(undefined);
|
||||
setFormData({});
|
||||
setHasErrors(true);
|
||||
setRefreshingSchema(false);
|
||||
setValidationMode('ValidateAndHide');
|
||||
errorsRef.current = [];
|
||||
lastDepSnapshotRef.current = '';
|
||||
dynamicDepsRef.current = {};
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
}, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]);
|
||||
|
||||
const handleStepAdvance = () => {
|
||||
if (selectedType) {
|
||||
fetchConfigSchema(selectedType);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep('type');
|
||||
setConfigSchema(null);
|
||||
setUiSchema(undefined);
|
||||
setFormData({});
|
||||
setValidationMode('ValidateAndHide');
|
||||
errorsRef.current = [];
|
||||
lastDepSnapshotRef.current = '';
|
||||
dynamicDepsRef.current = {};
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && semanticLayerUuid) {
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/semantic_layer/${semanticLayerUuid}`,
|
||||
jsonPayload: { name, configuration: formData },
|
||||
});
|
||||
addSuccessToast(t('Semantic layer updated'));
|
||||
} else {
|
||||
await SupersetClient.post({
|
||||
endpoint: '/api/v1/semantic_layer/',
|
||||
jsonPayload: { name, type: selectedType, configuration: formData },
|
||||
});
|
||||
addSuccessToast(t('Semantic layer created'));
|
||||
}
|
||||
onHide();
|
||||
} catch (error) {
|
||||
const clientError = await getClientErrorObject(error);
|
||||
addDangerToast(
|
||||
clientError.error ||
|
||||
(isEditMode
|
||||
? t('An error occurred while updating the semantic layer')
|
||||
: t('An error occurred while creating the semantic layer')),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (step === 'type') {
|
||||
handleStepAdvance();
|
||||
} else {
|
||||
setValidationMode('ValidateAndShow');
|
||||
if (errorsRef.current.length === 0) {
|
||||
handleCreate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const maybeRefreshSchema = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
if (!selectedType) return;
|
||||
|
||||
const dynamicDeps = dynamicDepsRef.current;
|
||||
if (Object.keys(dynamicDeps).length === 0) return;
|
||||
|
||||
// Check if any dynamic field has all dependencies satisfied
|
||||
const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
|
||||
areDependenciesSatisfied(deps, data),
|
||||
);
|
||||
if (!hasSatisfiedDeps) return;
|
||||
|
||||
// Only re-fetch if dependency values actually changed
|
||||
const snapshot = serializeDependencyValues(dynamicDeps, data);
|
||||
if (snapshot === lastDepSnapshotRef.current) return;
|
||||
lastDepSnapshotRef.current = snapshot;
|
||||
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
fetchConfigSchema(selectedType, data);
|
||||
}, SCHEMA_REFRESH_DEBOUNCE_MS);
|
||||
},
|
||||
[selectedType, fetchConfigSchema],
|
||||
);
|
||||
|
||||
const handleFormChange = useCallback(
|
||||
({
|
||||
data,
|
||||
errors,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
errors?: ErrorObject[];
|
||||
}) => {
|
||||
setFormData(data);
|
||||
errorsRef.current = errors ?? [];
|
||||
setHasErrors(errorsRef.current.length > 0);
|
||||
maybeRefreshSchema(data);
|
||||
},
|
||||
[maybeRefreshSchema],
|
||||
);
|
||||
|
||||
const selectedTypeName =
|
||||
types.find(type => type.id === selectedType)?.name ?? '';
|
||||
|
||||
const title = isEditMode
|
||||
? t('Edit %s', selectedTypeName || t('Semantic Layer'))
|
||||
: step === 'type'
|
||||
? t('New Semantic Layer')
|
||||
: t('Configure %s', selectedTypeName);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleSave}
|
||||
title={title}
|
||||
icon={isEditMode ? <Icons.EditOutlined /> : <Icons.PlusOutlined />}
|
||||
width={step === 'type' ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
|
||||
saveDisabled={
|
||||
step === 'type' ? !selectedType : saving || !name.trim() || hasErrors
|
||||
}
|
||||
saveText={
|
||||
step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')
|
||||
}
|
||||
saveLoading={saving}
|
||||
contentLoading={loading}
|
||||
>
|
||||
{step === 'type' ? (
|
||||
<>
|
||||
<ModalFormField label={t('Type')}>
|
||||
<Select
|
||||
ariaLabel={t('Semantic layer type')}
|
||||
placeholder={t('Select a semantic layer type')}
|
||||
value={selectedType}
|
||||
onChange={value => setSelectedType(value as string)}
|
||||
options={types.map(type => ({
|
||||
value: type.id,
|
||||
label: type.name,
|
||||
}))}
|
||||
getPopupContainer={() => document.body}
|
||||
dropdownAlign={{
|
||||
points: ['tl', 'bl'],
|
||||
offset: [0, 4],
|
||||
overflow: { adjustX: 0, adjustY: 1 },
|
||||
}}
|
||||
/>
|
||||
</ModalFormField>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!isEditMode && (
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
icon={<Icons.CaretLeftOutlined iconSize="s" />}
|
||||
onClick={handleBack}
|
||||
>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
)}
|
||||
<ModalFormField label={t('Name')} required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('Name of the semantic layer')}
|
||||
/>
|
||||
</ModalFormField>
|
||||
{configSchema && (
|
||||
<JsonForms
|
||||
schema={configSchema}
|
||||
uischema={uiSchema}
|
||||
data={formData}
|
||||
renderers={renderers}
|
||||
cells={cellRegistryEntries}
|
||||
config={{ refreshingSchema, formData }}
|
||||
validationMode={validationMode}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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 userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
|
||||
|
||||
import SemanticViewEditModal from './SemanticViewEditModal';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
SupersetClient: {
|
||||
...jest.requireActual('@superset-ui/core').SupersetClient,
|
||||
put: jest.fn(),
|
||||
},
|
||||
getClientErrorObject: jest.fn(() => Promise.resolve({ error: '' })),
|
||||
}));
|
||||
|
||||
const mockedPut = SupersetClient.put as jest.Mock;
|
||||
const mockedGetClientErrorObject = getClientErrorObject as jest.Mock;
|
||||
|
||||
const createProps = () => ({
|
||||
show: true,
|
||||
onHide: jest.fn(),
|
||||
onSave: jest.fn(),
|
||||
addDangerToast: jest.fn(),
|
||||
addSuccessToast: jest.fn(),
|
||||
semanticView: {
|
||||
id: 7,
|
||||
table_name: 'orders_semantic_view',
|
||||
description: 'old description',
|
||||
cache_timeout: 60,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockedPut.mockReset();
|
||||
mockedGetClientErrorObject.mockReset();
|
||||
mockedGetClientErrorObject.mockResolvedValue({ error: '' });
|
||||
});
|
||||
|
||||
test('saves semantic view and refreshes list', async () => {
|
||||
mockedPut.mockResolvedValue({});
|
||||
const props = createProps();
|
||||
|
||||
render(<SemanticViewEditModal {...props} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedPut).toHaveBeenCalledWith({
|
||||
endpoint: '/api/v1/semantic_view/7',
|
||||
jsonPayload: {
|
||||
description: 'old description',
|
||||
cache_timeout: 60,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(props.addSuccessToast).toHaveBeenCalledWith('Semantic view updated');
|
||||
expect(props.onSave).toHaveBeenCalled();
|
||||
expect(props.onHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows backend error toast when save fails', async () => {
|
||||
mockedPut.mockRejectedValue(new Error('save failed'));
|
||||
mockedGetClientErrorObject.mockResolvedValue({
|
||||
error: 'Semantic view failed to save',
|
||||
});
|
||||
const props = createProps();
|
||||
|
||||
render(<SemanticViewEditModal {...props} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addDangerToast).toHaveBeenCalledWith(
|
||||
'Semantic view failed to save',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
|
||||
import { Input, InputNumber } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import {
|
||||
StandardModal,
|
||||
ModalFormField,
|
||||
MODAL_STANDARD_WIDTH,
|
||||
} from 'src/components/Modal';
|
||||
|
||||
type InputNumberValue = number | null;
|
||||
|
||||
interface SemanticViewEditModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSave: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
semanticView: {
|
||||
id: number;
|
||||
table_name: string;
|
||||
description?: string | null;
|
||||
cache_timeout?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function SemanticViewEditModal({
|
||||
show,
|
||||
onHide,
|
||||
onSave,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
semanticView,
|
||||
}: SemanticViewEditModalProps) {
|
||||
const [description, setDescription] = useState<string>('');
|
||||
const [cacheTimeout, setCacheTimeout] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (semanticView) {
|
||||
setDescription(semanticView.description || '');
|
||||
setCacheTimeout(semanticView.cache_timeout ?? null);
|
||||
}
|
||||
}, [semanticView]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!semanticView) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/semantic_view/${semanticView.id}`,
|
||||
jsonPayload: {
|
||||
description: description || null,
|
||||
cache_timeout: cacheTimeout,
|
||||
},
|
||||
});
|
||||
addSuccessToast(t('Semantic view updated'));
|
||||
onSave();
|
||||
onHide();
|
||||
} catch (error) {
|
||||
const clientError = await getClientErrorObject(error);
|
||||
addDangerToast(
|
||||
clientError.error ||
|
||||
t('An error occurred while saving the semantic view'),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleSave}
|
||||
title={t('Edit %s', semanticView?.table_name || '')}
|
||||
icon={<Icons.EditOutlined />}
|
||||
isEditMode
|
||||
width={MODAL_STANDARD_WIDTH}
|
||||
saveLoading={saving}
|
||||
>
|
||||
<ModalFormField label={t('Description')}>
|
||||
<Input.TextArea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</ModalFormField>
|
||||
<ModalFormField label={t('Cache timeout')}>
|
||||
<InputNumber
|
||||
value={cacheTimeout}
|
||||
onChange={value => setCacheTimeout(value as InputNumberValue)}
|
||||
min={0}
|
||||
placeholder={t('Duration in seconds')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</ModalFormField>
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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 { renderHook } from '@testing-library/react-hooks';
|
||||
import { Provider } from 'react-redux';
|
||||
import { ReactNode } from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { usePermissions } from './usePermissions';
|
||||
|
||||
const mockStore = configureStore([]);
|
||||
|
||||
const rolesWithAllPerms = {
|
||||
Admin: [
|
||||
['can_csv', 'Superset'],
|
||||
['can_export_data', 'Superset'],
|
||||
['can_export_image', 'Superset'],
|
||||
['can_copy_clipboard', 'Superset'],
|
||||
['can_explore', 'Superset'],
|
||||
],
|
||||
};
|
||||
|
||||
const rolesWithoutExportPerms = {
|
||||
Gamma: [
|
||||
['can_explore', 'Superset'],
|
||||
['can_copy_clipboard', 'Superset'],
|
||||
],
|
||||
};
|
||||
|
||||
const rolesWithLegacyCsvOnly = {
|
||||
CustomRole: [
|
||||
['can_csv', 'Superset'],
|
||||
['can_explore', 'Superset'],
|
||||
],
|
||||
};
|
||||
|
||||
function createWrapper(roles: Record<string, string[][]>) {
|
||||
const store = mockStore({ user: { roles } });
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<Provider store={store}>{children}</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { isFeatureEnabled } = require('@superset-ui/core');
|
||||
|
||||
test('returns canExportData true when user has can_export_data', () => {
|
||||
isFeatureEnabled.mockReturnValue(true);
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(rolesWithAllPerms),
|
||||
});
|
||||
expect(result.current.canExportData).toBe(true);
|
||||
});
|
||||
|
||||
test('returns canExportImage true when user has can_export_image', () => {
|
||||
isFeatureEnabled.mockReturnValue(true);
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(rolesWithAllPerms),
|
||||
});
|
||||
expect(result.current.canExportImage).toBe(true);
|
||||
});
|
||||
|
||||
test('returns canCopyClipboard true when user has can_copy_clipboard', () => {
|
||||
isFeatureEnabled.mockReturnValue(true);
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(rolesWithAllPerms),
|
||||
});
|
||||
expect(result.current.canCopyClipboard).toBe(true);
|
||||
});
|
||||
|
||||
test('returns canExportData false when user lacks can_export_data', () => {
|
||||
isFeatureEnabled.mockReturnValue(true);
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(rolesWithoutExportPerms),
|
||||
});
|
||||
expect(result.current.canExportData).toBe(false);
|
||||
});
|
||||
|
||||
test('returns canExportImage false when user lacks can_export_image', () => {
|
||||
isFeatureEnabled.mockReturnValue(true);
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(rolesWithoutExportPerms),
|
||||
});
|
||||
expect(result.current.canExportImage).toBe(false);
|
||||
});
|
||||
|
||||
test('canDownload uses can_export_data when GRANULAR_EXPORT_CONTROLS enabled', () => {
|
||||
isFeatureEnabled.mockReturnValue(true);
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(rolesWithAllPerms),
|
||||
});
|
||||
expect(result.current.canDownload).toBe(true);
|
||||
});
|
||||
|
||||
test('canDownload uses can_csv when GRANULAR_EXPORT_CONTROLS disabled', () => {
|
||||
isFeatureEnabled.mockReturnValue(false);
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(rolesWithLegacyCsvOnly),
|
||||
});
|
||||
expect(result.current.canDownload).toBe(true);
|
||||
});
|
||||
|
||||
test('canDownload false when GRANULAR_EXPORT_CONTROLS enabled but no can_export_data', () => {
|
||||
isFeatureEnabled.mockReturnValue(true);
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(rolesWithoutExportPerms),
|
||||
});
|
||||
expect(result.current.canDownload).toBe(false);
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
@@ -30,9 +31,21 @@ export const usePermissions = () => {
|
||||
const canDatasourceSamples = useSelector((state: RootState) =>
|
||||
findPermission('can_samples', 'Datasource', state.user?.roles),
|
||||
);
|
||||
const canDownload = useSelector((state: RootState) =>
|
||||
const canCsvLegacy = useSelector((state: RootState) =>
|
||||
findPermission('can_csv', 'Superset', state.user?.roles),
|
||||
);
|
||||
const canExportData = useSelector((state: RootState) =>
|
||||
findPermission('can_export_data', 'Superset', state.user?.roles),
|
||||
);
|
||||
const canExportImage = useSelector((state: RootState) =>
|
||||
findPermission('can_export_image', 'Superset', state.user?.roles),
|
||||
);
|
||||
const canCopyClipboard = useSelector((state: RootState) =>
|
||||
findPermission('can_copy_clipboard', 'Superset', state.user?.roles),
|
||||
);
|
||||
const canDownload = isFeatureEnabled(FeatureFlag.GranularExportControls)
|
||||
? canExportData
|
||||
: canCsvLegacy;
|
||||
const canDrill = useSelector((state: RootState) =>
|
||||
findPermission('can_drill', 'Dashboard', state.user?.roles),
|
||||
);
|
||||
@@ -55,6 +68,9 @@ export const usePermissions = () => {
|
||||
canWriteExploreFormData,
|
||||
canDatasourceSamples,
|
||||
canDownload,
|
||||
canExportData,
|
||||
canExportImage,
|
||||
canCopyClipboard,
|
||||
canDrill,
|
||||
canDrillBy,
|
||||
canDrillToDetail,
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry, SupersetClient } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import {
|
||||
getExtensionsRegistry,
|
||||
SupersetClient,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
} from '@superset-ui/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import type { CellProps } from 'react-table';
|
||||
import rison from 'rison';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueryParams, BooleanParam } from 'use-query-params';
|
||||
@@ -33,7 +39,9 @@ import {
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import {
|
||||
Button,
|
||||
DeleteModal,
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
List,
|
||||
Loading,
|
||||
@@ -43,6 +51,7 @@ import {
|
||||
ListView,
|
||||
ListViewFilterOperator as FilterOperator,
|
||||
ListViewFilters,
|
||||
type ListViewFetchDataConfig,
|
||||
} from 'src/components';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
@@ -55,10 +64,12 @@ import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import type { MenuObjectProps } from 'src/types/bootstrapTypes';
|
||||
import DatabaseModal from 'src/features/databases/DatabaseModal';
|
||||
import UploadDataModal from 'src/features/databases/UploadDataModel';
|
||||
import SemanticLayerModal from 'src/features/semanticLayers/SemanticLayerModal';
|
||||
import { DatabaseObject } from 'src/features/databases/types';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
import type Owner from 'src/types/Owner';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
const DatabaseDeleteRelatedExtension = extensionsRegistry.get(
|
||||
@@ -70,6 +81,13 @@ const dbConfigExtraExtension = extensionsRegistry.get(
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type ConnectionItem = DatabaseObject & {
|
||||
source_type?: 'database' | 'semantic_layer';
|
||||
sl_type?: string;
|
||||
changed_by?: Owner;
|
||||
changed_on_delta_humanized?: string;
|
||||
};
|
||||
|
||||
interface DatabaseDeleteObject extends DatabaseObject {
|
||||
charts: any;
|
||||
dashboards: any;
|
||||
@@ -108,20 +126,106 @@ function DatabaseList({
|
||||
addSuccessToast,
|
||||
user,
|
||||
}: DatabaseListProps) {
|
||||
const theme = useTheme();
|
||||
const showSemanticLayers = isFeatureEnabled(FeatureFlag.SemanticLayers);
|
||||
|
||||
// Standard database list view resource (used when SL flag is OFF)
|
||||
const {
|
||||
state: {
|
||||
loading,
|
||||
resourceCount: databaseCount,
|
||||
resourceCollection: databases,
|
||||
loading: dbLoading,
|
||||
resourceCount: dbCount,
|
||||
resourceCollection: dbCollection,
|
||||
},
|
||||
hasPerm,
|
||||
fetchData,
|
||||
refreshData,
|
||||
fetchData: dbFetchData,
|
||||
refreshData: dbRefreshData,
|
||||
} = useListViewResource<DatabaseObject>(
|
||||
'database',
|
||||
t('database'),
|
||||
addDangerToast,
|
||||
);
|
||||
|
||||
// Combined endpoint state (used when SL flag is ON)
|
||||
const [combinedItems, setCombinedItems] = useState<ConnectionItem[]>([]);
|
||||
const [combinedCount, setCombinedCount] = useState(0);
|
||||
const [combinedLoading, setCombinedLoading] = useState(true);
|
||||
const [lastFetchConfig, setLastFetchConfig] =
|
||||
useState<ListViewFetchDataConfig | null>(null);
|
||||
|
||||
const combinedFetchData = useCallback(
|
||||
(config: ListViewFetchDataConfig) => {
|
||||
setLastFetchConfig(config);
|
||||
setCombinedLoading(true);
|
||||
const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
|
||||
|
||||
const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
|
||||
const otherFilters = filterValues
|
||||
.filter(f => f.id !== 'source_type')
|
||||
.filter(
|
||||
({ value }) => value !== '' && value !== null && value !== undefined,
|
||||
)
|
||||
.map(({ id, operator: opr, value }) => ({
|
||||
col: id,
|
||||
opr,
|
||||
value:
|
||||
value && typeof value === 'object' && 'value' in value
|
||||
? value.value
|
||||
: value,
|
||||
}));
|
||||
|
||||
const sourceTypeValue =
|
||||
sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object'
|
||||
? (sourceTypeFilter.value as { value: string }).value
|
||||
: (sourceTypeFilter?.value as string | undefined);
|
||||
if (sourceTypeValue) {
|
||||
otherFilters.push({
|
||||
col: 'source_type',
|
||||
opr: 'eq',
|
||||
value: sourceTypeValue,
|
||||
});
|
||||
}
|
||||
|
||||
const queryParams = rison.encode_uri({
|
||||
order_column: sortBy[0].id,
|
||||
order_direction: sortBy[0].desc ? 'desc' : 'asc',
|
||||
page: pageIndex,
|
||||
page_size: pageSize,
|
||||
...(otherFilters.length ? { filters: otherFilters } : {}),
|
||||
});
|
||||
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/semantic_layer/connections/?q=${queryParams}`,
|
||||
})
|
||||
.then(({ json = {} }) => {
|
||||
setCombinedItems(json.result);
|
||||
setCombinedCount(json.count);
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(t('An error occurred while fetching connections'));
|
||||
})
|
||||
.finally(() => {
|
||||
setCombinedLoading(false);
|
||||
});
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const combinedRefreshData = useCallback(() => {
|
||||
if (lastFetchConfig) {
|
||||
return combinedFetchData(lastFetchConfig);
|
||||
}
|
||||
return undefined;
|
||||
}, [lastFetchConfig, combinedFetchData]);
|
||||
|
||||
// Select the right data source based on feature flag
|
||||
const loading = showSemanticLayers ? combinedLoading : dbLoading;
|
||||
const databaseCount = showSemanticLayers ? combinedCount : dbCount;
|
||||
const databases: ConnectionItem[] = showSemanticLayers
|
||||
? combinedItems
|
||||
: dbCollection;
|
||||
const fetchData = showSemanticLayers ? combinedFetchData : dbFetchData;
|
||||
const refreshData = showSemanticLayers ? combinedRefreshData : dbRefreshData;
|
||||
|
||||
const fullUser = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
@@ -148,6 +252,13 @@ function DatabaseList({
|
||||
useState<boolean>(false);
|
||||
const [columnarUploadDataModalOpen, setColumnarUploadDataModalOpen] =
|
||||
useState<boolean>(false);
|
||||
const [semanticLayerModalOpen, setSemanticLayerModalOpen] =
|
||||
useState<boolean>(false);
|
||||
const [slCurrentlyEditing, setSlCurrentlyEditing] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [slCurrentlyDeleting, setSlCurrentlyDeleting] =
|
||||
useState<ConnectionItem | null>(null);
|
||||
|
||||
const [allowUploads, setAllowUploads] = useState<boolean>(false);
|
||||
const isAdmin = isUserAdmin(fullUser);
|
||||
@@ -320,18 +431,63 @@ function DatabaseList({
|
||||
};
|
||||
|
||||
if (canCreate) {
|
||||
menuData.buttons = [
|
||||
{
|
||||
'data-test': 'btn-create-database',
|
||||
icon: <Icons.PlusOutlined iconSize="m" />,
|
||||
name: t('Database'),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => {
|
||||
// Ensure modal will be opened in add mode
|
||||
handleDatabaseEditModal({ modalOpen: true });
|
||||
const openDatabaseModal = () =>
|
||||
handleDatabaseEditModal({ modalOpen: true });
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.SemanticLayers)) {
|
||||
menuData.buttons = [
|
||||
{
|
||||
name: t('New'),
|
||||
buttonStyle: 'primary',
|
||||
component: (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'database',
|
||||
label: t('Database'),
|
||||
onClick: openDatabaseModal,
|
||||
},
|
||||
{
|
||||
key: 'semantic-layer',
|
||||
label: t('Semantic Layer'),
|
||||
onClick: () => {
|
||||
setSemanticLayerModalOpen(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
data-test="btn-create-new"
|
||||
buttonStyle="primary"
|
||||
icon={<Icons.PlusOutlined iconSize="m" />}
|
||||
>
|
||||
{t('New')}
|
||||
<Icons.DownOutlined
|
||||
iconSize="s"
|
||||
css={css`
|
||||
margin-left: ${theme.sizeUnit * 1.5}px;
|
||||
margin-right: -${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
} else {
|
||||
menuData.buttons = [
|
||||
{
|
||||
'data-test': 'btn-create-database',
|
||||
icon: <Icons.PlusOutlined iconSize="m" />,
|
||||
name: t('Database'),
|
||||
buttonStyle: 'primary',
|
||||
onClick: openDatabaseModal,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const handleDatabaseExport = useCallback(
|
||||
@@ -345,7 +501,7 @@ function DatabaseList({
|
||||
await handleResourceExport('database', [database.id], () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the database'));
|
||||
}
|
||||
@@ -401,6 +557,23 @@ function DatabaseList({
|
||||
|
||||
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||
|
||||
function handleSemanticLayerDelete(item: ConnectionItem) {
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/semantic_layer/${item.uuid}`,
|
||||
}).then(
|
||||
() => {
|
||||
refreshData();
|
||||
addSuccessToast(t('Deleted: %s', item.database_name));
|
||||
setSlCurrentlyDeleting(null);
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('There was an issue deleting %s: %s', item.database_name, errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -413,7 +586,7 @@ function DatabaseList({
|
||||
accessor: 'backend',
|
||||
Header: t('Backend'),
|
||||
size: 'xl',
|
||||
disableSortBy: true, // TODO: api support for sorting by 'backend'
|
||||
disableSortBy: true,
|
||||
id: 'backend',
|
||||
},
|
||||
{
|
||||
@@ -427,13 +600,12 @@ function DatabaseList({
|
||||
<span>{t('AQE')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { allow_run_async: allowRunAsync },
|
||||
},
|
||||
}: {
|
||||
row: { original: { allow_run_async: boolean } };
|
||||
}) => <BooleanDisplay value={allowRunAsync} />,
|
||||
Cell: ({ row: { original } }: CellProps<ConnectionItem>) =>
|
||||
original.source_type === 'semantic_layer' ? (
|
||||
<span>–</span>
|
||||
) : (
|
||||
<BooleanDisplay value={Boolean(original.allow_run_async)} />
|
||||
),
|
||||
size: 'sm',
|
||||
id: 'allow_run_async',
|
||||
},
|
||||
@@ -448,33 +620,36 @@ function DatabaseList({
|
||||
<span>{t('DML')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { allow_dml: allowDML },
|
||||
},
|
||||
}: any) => <BooleanDisplay value={allowDML} />,
|
||||
Cell: ({ row: { original } }: CellProps<ConnectionItem>) =>
|
||||
original.source_type === 'semantic_layer' ? (
|
||||
<span>–</span>
|
||||
) : (
|
||||
<BooleanDisplay value={Boolean(original.allow_dml)} />
|
||||
),
|
||||
size: 'sm',
|
||||
id: 'allow_dml',
|
||||
},
|
||||
{
|
||||
accessor: 'allow_file_upload',
|
||||
Header: t('File upload'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { allow_file_upload: allowFileUpload },
|
||||
},
|
||||
}: any) => <BooleanDisplay value={allowFileUpload} />,
|
||||
Cell: ({ row: { original } }: CellProps<ConnectionItem>) =>
|
||||
original.source_type === 'semantic_layer' ? (
|
||||
<span>–</span>
|
||||
) : (
|
||||
<BooleanDisplay value={Boolean(original.allow_file_upload)} />
|
||||
),
|
||||
size: 'md',
|
||||
id: 'allow_file_upload',
|
||||
},
|
||||
{
|
||||
accessor: 'expose_in_sqllab',
|
||||
Header: t('Expose in SQL Lab'),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { expose_in_sqllab: exposeInSqllab },
|
||||
},
|
||||
}: any) => <BooleanDisplay value={exposeInSqllab} />,
|
||||
Cell: ({ row: { original } }: CellProps<ConnectionItem>) =>
|
||||
original.source_type === 'semantic_layer' ? (
|
||||
<span>–</span>
|
||||
) : (
|
||||
<BooleanDisplay value={Boolean(original.expose_in_sqllab)} />
|
||||
),
|
||||
size: 'md',
|
||||
id: 'expose_in_sqllab',
|
||||
},
|
||||
@@ -486,7 +661,9 @@ function DatabaseList({
|
||||
changed_on_delta_humanized: changedOn,
|
||||
},
|
||||
},
|
||||
}: any) => <ModifiedInfo date={changedOn} user={changedBy} />,
|
||||
}: CellProps<ConnectionItem>) => (
|
||||
<ModifiedInfo date={changedOn || ''} user={changedBy} />
|
||||
),
|
||||
Header: t('Last modified'),
|
||||
accessor: 'changed_on_delta_humanized',
|
||||
size: 'xl',
|
||||
@@ -494,6 +671,48 @@ function DatabaseList({
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: any) => {
|
||||
const isSemanticLayer = original.source_type === 'semantic_layer';
|
||||
|
||||
if (isSemanticLayer) {
|
||||
if (!canEdit && !canDelete) return null;
|
||||
return (
|
||||
<Actions className="actions">
|
||||
{canDelete && (
|
||||
<Tooltip
|
||||
id="delete-action-tooltip"
|
||||
title={t('Delete')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={() => setSlCurrentlyDeleting(original)}
|
||||
>
|
||||
<Icons.DeleteOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
id="edit-action-tooltip"
|
||||
title={t('Edit')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={() => setSlCurrentlyEditing(original.uuid)}
|
||||
>
|
||||
<Icons.EditOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
|
||||
const handleEdit = () =>
|
||||
handleDatabaseEditModal({ database: original, modalOpen: true });
|
||||
const handleDelete = () => openDatabaseDeleteModal(original);
|
||||
@@ -579,6 +798,12 @@ function DatabaseList({
|
||||
hidden: !canEdit && !canDelete,
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
accessor: 'source_type',
|
||||
hidden: true,
|
||||
disableSortBy: true,
|
||||
id: 'source_type',
|
||||
},
|
||||
{
|
||||
accessor: QueryObjectColumns.ChangedBy,
|
||||
hidden: true,
|
||||
@@ -596,8 +821,8 @@ function DatabaseList({
|
||||
],
|
||||
);
|
||||
|
||||
const filters: ListViewFilters = useMemo(
|
||||
() => [
|
||||
const filters: ListViewFilters = useMemo(() => {
|
||||
const baseFilters: ListViewFilters = [
|
||||
{
|
||||
Header: t('Name'),
|
||||
key: 'search',
|
||||
@@ -605,62 +830,83 @@ function DatabaseList({
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Expose in SQL Lab'),
|
||||
key: 'expose_in_sql_lab',
|
||||
id: 'expose_in_sqllab',
|
||||
];
|
||||
|
||||
if (showSemanticLayers) {
|
||||
baseFilters.push({
|
||||
Header: t('Source'),
|
||||
key: 'source_type',
|
||||
id: 'source_type',
|
||||
input: 'select',
|
||||
operator: FilterOperator.Equals,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: [
|
||||
{ label: t('Yes'), value: true },
|
||||
{ label: t('No'), value: false },
|
||||
{ label: t('Database'), value: 'database' },
|
||||
{ label: t('Semantic Layer'), value: 'semantic_layer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: (
|
||||
<Tooltip
|
||||
id="allow-run-async-filter-header-tooltip"
|
||||
title={t('Asynchronous query execution')}
|
||||
placement="top"
|
||||
>
|
||||
<span>{t('AQE')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
key: 'allow_run_async',
|
||||
id: 'allow_run_async',
|
||||
input: 'select',
|
||||
operator: FilterOperator.Equals,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: [
|
||||
{ label: t('Yes'), value: true },
|
||||
{ label: t('No'), value: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: t('Modified by'),
|
||||
key: 'changed_by',
|
||||
id: 'changed_by',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
fetchSelects: createFetchRelated(
|
||||
'database',
|
||||
'changed_by',
|
||||
createErrorHandler(errMsg =>
|
||||
t(
|
||||
'An error occurred while fetching dataset datasource values: %s',
|
||||
errMsg,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!showSemanticLayers) {
|
||||
baseFilters.push(
|
||||
{
|
||||
Header: t('Expose in SQL Lab'),
|
||||
key: 'expose_in_sql_lab',
|
||||
id: 'expose_in_sqllab',
|
||||
input: 'select',
|
||||
operator: FilterOperator.Equals,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: [
|
||||
{ label: t('Yes'), value: true },
|
||||
{ label: t('No'), value: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: (
|
||||
<Tooltip
|
||||
id="allow-run-async-filter-header-tooltip"
|
||||
title={t('Asynchronous query execution')}
|
||||
placement="top"
|
||||
>
|
||||
<span>{t('AQE')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
user,
|
||||
),
|
||||
paginate: true,
|
||||
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
|
||||
},
|
||||
],
|
||||
[user],
|
||||
);
|
||||
key: 'allow_run_async',
|
||||
id: 'allow_run_async',
|
||||
input: 'select',
|
||||
operator: FilterOperator.Equals,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: [
|
||||
{ label: t('Yes'), value: true },
|
||||
{ label: t('No'), value: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: t('Modified by'),
|
||||
key: 'changed_by',
|
||||
id: 'changed_by',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
fetchSelects: createFetchRelated(
|
||||
'database',
|
||||
'changed_by',
|
||||
createErrorHandler(errMsg =>
|
||||
t(
|
||||
'An error occurred while fetching dataset datasource values: %s',
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
user,
|
||||
),
|
||||
paginate: true,
|
||||
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return baseFilters;
|
||||
}, [showSemanticLayers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -703,6 +949,48 @@ function DatabaseList({
|
||||
allowedExtensions={COLUMNAR_EXTENSIONS}
|
||||
type="columnar"
|
||||
/>
|
||||
<SemanticLayerModal
|
||||
show={semanticLayerModalOpen}
|
||||
onHide={() => {
|
||||
setSemanticLayerModalOpen(false);
|
||||
refreshData();
|
||||
}}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
/>
|
||||
<SemanticLayerModal
|
||||
show={!!slCurrentlyEditing}
|
||||
onHide={() => {
|
||||
setSlCurrentlyEditing(null);
|
||||
refreshData();
|
||||
}}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
semanticLayerUuid={slCurrentlyEditing ?? undefined}
|
||||
/>
|
||||
{slCurrentlyDeleting && (
|
||||
<DeleteModal
|
||||
description={
|
||||
<p>
|
||||
{t('Are you sure you want to delete')}{' '}
|
||||
<b>{slCurrentlyDeleting.database_name}</b>?
|
||||
</p>
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (slCurrentlyDeleting) {
|
||||
handleSemanticLayerDelete(slCurrentlyDeleting);
|
||||
}
|
||||
}}
|
||||
onHide={() => setSlCurrentlyDeleting(null)}
|
||||
open
|
||||
title={
|
||||
<ModalTitleWithIcon
|
||||
icon={<Icons.DeleteOutlined />}
|
||||
title={t('Delete Semantic Layer?')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{databaseCurrentlyDeleting && (
|
||||
<DeleteModal
|
||||
description={
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
mockRelatedCharts,
|
||||
mockRelatedDashboards,
|
||||
mockHandleResourceExport,
|
||||
mockDatasetListEndpoints,
|
||||
API_ENDPOINTS,
|
||||
} from './DatasetList.testHelpers';
|
||||
|
||||
@@ -98,7 +99,7 @@ test('typing in search triggers debounced API call with search filter', async ()
|
||||
|
||||
// Record initial API calls
|
||||
const initialCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Type search query and submit with Enter to trigger the debounced fetch
|
||||
@@ -107,14 +108,16 @@ test('typing in search triggers debounced API call with search filter', async ()
|
||||
// Wait for debounced API call
|
||||
await waitFor(
|
||||
() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(initialCallCount);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// Verify the latest API call includes search filter in URL
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
|
||||
const latestCall = calls[calls.length - 1];
|
||||
const { url } = latestCall;
|
||||
|
||||
@@ -136,8 +139,7 @@ test('typing in search triggers debounced API call with search filter', async ()
|
||||
test('500 error triggers danger toast with error message', async () => {
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
status: 500,
|
||||
body: { message: 'Internal Server Error' },
|
||||
});
|
||||
@@ -173,8 +175,7 @@ test('500 error triggers danger toast with error message', async () => {
|
||||
test('network timeout triggers danger toast', async () => {
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
throws: new Error('Network timeout'),
|
||||
});
|
||||
|
||||
@@ -213,8 +214,7 @@ test('clicking delete opens modal with related objects count', async () => {
|
||||
// Set up delete mocks
|
||||
setupDeleteMocks(datasetToDelete.id);
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetToDelete],
|
||||
count: 1,
|
||||
});
|
||||
@@ -254,8 +254,7 @@ test('clicking delete opens modal with related objects count', async () => {
|
||||
test('clicking export calls handleResourceExport with dataset ID', async () => {
|
||||
const datasetToExport = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetToExport],
|
||||
count: 1,
|
||||
});
|
||||
@@ -288,8 +287,7 @@ test('clicking duplicate opens modal and submits duplicate request', async () =>
|
||||
kind: 'virtual',
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetToDuplicate],
|
||||
count: 1,
|
||||
});
|
||||
@@ -312,7 +310,7 @@ test('clicking duplicate opens modal and submits duplicate request', async () =>
|
||||
|
||||
// Track initial dataset list API calls BEFORE duplicate action
|
||||
const initialDatasetCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
const row = screen.getByText(datasetToDuplicate.table_name).closest('tr');
|
||||
@@ -355,7 +353,9 @@ test('clicking duplicate opens modal and submits duplicate request', async () =>
|
||||
// Verify refreshData() is called (observable via new dataset list API call)
|
||||
await waitFor(
|
||||
() => {
|
||||
const datasetCalls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const datasetCalls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(datasetCalls.length).toBeGreaterThan(initialDatasetCallCount);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
@@ -376,8 +376,7 @@ test('certified dataset shows badge and tooltip with certification details', asy
|
||||
}),
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [certifiedDataset],
|
||||
count: 1,
|
||||
});
|
||||
@@ -417,8 +416,7 @@ test('dataset with warning shows icon and tooltip with markdown content', async
|
||||
}),
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetWithWarning],
|
||||
count: 1,
|
||||
});
|
||||
@@ -452,8 +450,7 @@ test('dataset with warning shows icon and tooltip with markdown content', async
|
||||
test('dataset name links to Explore with correct URL and accessible label', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
mockAdminUser,
|
||||
mockDatasets,
|
||||
setupBulkDeleteMocks,
|
||||
mockDatasetListEndpoints,
|
||||
API_ENDPOINTS,
|
||||
} from './DatasetList.testHelpers';
|
||||
|
||||
@@ -72,8 +73,7 @@ test('ListView provider correctly merges filter + sort + pagination state on ref
|
||||
// the ListView provider correctly merges them for the API call.
|
||||
// Component tests verify individual pieces persist; this verifies they COMBINE correctly.
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: mockDatasets,
|
||||
count: mockDatasets.length,
|
||||
});
|
||||
@@ -91,31 +91,33 @@ test('ListView provider correctly merges filter + sort + pagination state on ref
|
||||
});
|
||||
|
||||
const callsBeforeSort = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
await userEvent.click(nameHeader);
|
||||
|
||||
// Wait for sort-triggered refetch to complete before applying filter
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS).length,
|
||||
fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED).length,
|
||||
).toBeGreaterThan(callsBeforeSort);
|
||||
});
|
||||
|
||||
// 2. Apply a filter using selectOption helper
|
||||
const beforeFilterCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
await selectOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(beforeFilterCallCount);
|
||||
});
|
||||
|
||||
// 3. Verify the final API call contains ALL three state pieces merged correctly
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
|
||||
const latestCall = calls[calls.length - 1];
|
||||
const { url } = latestCall;
|
||||
|
||||
@@ -151,8 +153,7 @@ test('bulk action orchestration: selection → action → cleanup cycle works co
|
||||
|
||||
setupBulkDeleteMocks();
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: mockDatasets,
|
||||
count: mockDatasets.length,
|
||||
});
|
||||
@@ -218,7 +219,7 @@ test('bulk action orchestration: selection → action → cleanup cycle works co
|
||||
|
||||
// Capture datasets call count before confirming
|
||||
const datasetsCallCountBeforeDelete = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
const confirmButton = within(modal)
|
||||
@@ -242,7 +243,7 @@ test('bulk action orchestration: selection → action → cleanup cycle works co
|
||||
// Wait for datasets refetch after delete
|
||||
await waitFor(() => {
|
||||
const datasetsCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
expect(datasetsCallCount).toBeGreaterThan(datasetsCallCountBeforeDelete);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
mockHandleResourceExport,
|
||||
assertOnlyExpectedCalls,
|
||||
API_ENDPOINTS,
|
||||
mockDatasetListEndpoints,
|
||||
getDeleteRouteName,
|
||||
} from './DatasetList.testHelpers';
|
||||
|
||||
@@ -113,8 +114,7 @@ const setupErrorTestScenario = ({
|
||||
});
|
||||
|
||||
// Configure fetchMock to return single dataset
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
// Render component with toast mocks
|
||||
renderDatasetList(mockAdminUser, {
|
||||
@@ -157,7 +157,7 @@ test('required API endpoints are called and no unmocked calls on initial render'
|
||||
// assertOnlyExpectedCalls checks: 1) no unmatched calls, 2) each expected endpoint was called
|
||||
assertOnlyExpectedCalls([
|
||||
API_ENDPOINTS.DATASETS_INFO, // Permission check
|
||||
API_ENDPOINTS.DATASETS, // Main dataset list data
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED, // Main dataset list data
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -197,8 +197,7 @@ test('renders all required column headers', async () => {
|
||||
test('displays dataset name in Name column', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -211,8 +210,7 @@ test('displays dataset type as Physical or Virtual', async () => {
|
||||
const physicalDataset = mockDatasets[0]; // kind: 'physical'
|
||||
const virtualDataset = mockDatasets[1]; // kind: 'virtual'
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [physicalDataset, virtualDataset],
|
||||
count: 2,
|
||||
});
|
||||
@@ -229,8 +227,7 @@ test('displays dataset type as Physical or Virtual', async () => {
|
||||
test('displays database name in Database column', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -244,8 +241,7 @@ test('displays database name in Database column', async () => {
|
||||
test('displays schema name in Schema column', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -257,8 +253,7 @@ test('displays schema name in Schema column', async () => {
|
||||
test('displays last modified date in humanized format', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -283,7 +278,7 @@ test('sorting by Name column updates API call with sort parameter', async () =>
|
||||
|
||||
// Record initial calls
|
||||
const initialCalls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Click Name header to sort
|
||||
@@ -291,12 +286,14 @@ test('sorting by Name column updates API call with sort parameter', async () =>
|
||||
|
||||
// Wait for new API call
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(initialCalls);
|
||||
});
|
||||
|
||||
// Verify latest call includes sort parameter
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
|
||||
const latestCall = calls[calls.length - 1];
|
||||
const { url } = latestCall;
|
||||
|
||||
@@ -317,17 +314,19 @@ test('sorting by Database column updates sort parameter', async () => {
|
||||
});
|
||||
|
||||
const initialCalls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
await userEvent.click(databaseHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(initialCalls);
|
||||
});
|
||||
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
|
||||
const { url } = calls[calls.length - 1];
|
||||
expect(url).toMatch(/order_column|sort/);
|
||||
});
|
||||
@@ -345,17 +344,19 @@ test('sorting by Last modified column updates sort parameter', async () => {
|
||||
});
|
||||
|
||||
const initialCalls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
await userEvent.click(modifiedHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(initialCalls);
|
||||
});
|
||||
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
|
||||
const { url } = calls[calls.length - 1];
|
||||
expect(url).toMatch(/order_column|sort/);
|
||||
});
|
||||
@@ -363,8 +364,7 @@ test('sorting by Last modified column updates sort parameter', async () => {
|
||||
test('export button triggers handleResourceExport with dataset ID', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -392,8 +392,7 @@ test('delete button opens modal with dataset details', async () => {
|
||||
|
||||
setupDeleteMocks(dataset.id);
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -415,8 +414,7 @@ test('delete action successfully deletes dataset and refreshes list', async () =
|
||||
const datasetToDelete = mockDatasets[0];
|
||||
setupDeleteMocks(datasetToDelete.id);
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetToDelete],
|
||||
count: 1,
|
||||
});
|
||||
@@ -442,7 +440,7 @@ test('delete action successfully deletes dataset and refreshes list', async () =
|
||||
|
||||
// Track API calls before confirm
|
||||
const callsBefore = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Click confirm - find the danger button (last delete button in modal)
|
||||
@@ -468,7 +466,7 @@ test('delete action successfully deletes dataset and refreshes list', async () =
|
||||
// List refreshes
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS).length,
|
||||
fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED).length,
|
||||
).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
});
|
||||
@@ -477,8 +475,7 @@ test('delete action cancel closes modal without deleting', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
setupDeleteMocks(dataset.id);
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -518,8 +515,7 @@ test('duplicate action successfully duplicates virtual dataset', async () => {
|
||||
const virtualDataset = mockDatasets[1]; // Virtual dataset (kind: 'virtual')
|
||||
setupDuplicateMocks();
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [virtualDataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [virtualDataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser, {
|
||||
addSuccessToast: mockAddSuccessToast,
|
||||
@@ -542,7 +538,7 @@ test('duplicate action successfully duplicates virtual dataset', async () => {
|
||||
|
||||
// Track API calls before submit
|
||||
const callsBefore = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Submit
|
||||
@@ -564,7 +560,7 @@ test('duplicate action successfully duplicates virtual dataset', async () => {
|
||||
// List refreshes
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS).length,
|
||||
fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED).length,
|
||||
).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
});
|
||||
@@ -573,8 +569,7 @@ test('duplicate button visible only for virtual datasets', async () => {
|
||||
const physicalDataset = mockDatasets[0]; // kind: 'physical'
|
||||
const virtualDataset = mockDatasets[1]; // kind: 'virtual'
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [physicalDataset, virtualDataset],
|
||||
count: 2,
|
||||
});
|
||||
@@ -633,8 +628,7 @@ test('bulk select enables checkboxes', async () => {
|
||||
}, 30000);
|
||||
|
||||
test('selecting all datasets shows correct count in toolbar', async () => {
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: mockDatasets,
|
||||
count: mockDatasets.length,
|
||||
});
|
||||
@@ -673,8 +667,7 @@ test('selecting all datasets shows correct count in toolbar', async () => {
|
||||
}, 30000);
|
||||
|
||||
test('bulk export triggers export with selected IDs', async () => {
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [mockDatasets[0]],
|
||||
count: 1,
|
||||
});
|
||||
@@ -716,8 +709,7 @@ test('bulk export triggers export with selected IDs', async () => {
|
||||
test('bulk delete opens confirmation modal', async () => {
|
||||
setupBulkDeleteMocks();
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [mockDatasets[0]],
|
||||
count: 1,
|
||||
});
|
||||
@@ -823,8 +815,7 @@ test('certified badge appears for certified datasets', async () => {
|
||||
}),
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [certifiedDataset],
|
||||
count: 1,
|
||||
});
|
||||
@@ -854,8 +845,7 @@ test('warning icon appears for datasets with warnings', async () => {
|
||||
}),
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetWithWarning],
|
||||
count: 1,
|
||||
});
|
||||
@@ -883,8 +873,7 @@ test('info tooltip appears for datasets with descriptions', async () => {
|
||||
description: 'Sales data from Q4 2024',
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetWithDescription],
|
||||
count: 1,
|
||||
});
|
||||
@@ -909,8 +898,7 @@ test('info tooltip appears for datasets with descriptions', async () => {
|
||||
test('dataset name links to Explore page', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -930,8 +918,7 @@ test('dataset name links to Explore page', async () => {
|
||||
test('physical dataset shows delete, export, and edit actions (no duplicate)', async () => {
|
||||
const physicalDataset = mockDatasets[0]; // kind: 'physical'
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [physicalDataset],
|
||||
count: 1,
|
||||
});
|
||||
@@ -962,8 +949,7 @@ test('physical dataset shows delete, export, and edit actions (no duplicate)', a
|
||||
test('virtual dataset shows delete, export, edit, and duplicate actions', async () => {
|
||||
const virtualDataset = mockDatasets[1]; // kind: 'virtual'
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [virtualDataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [virtualDataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -992,8 +978,7 @@ test('edit action is enabled for dataset owner', async () => {
|
||||
owners: [{ id: mockAdminUser.userId, username: 'admin' }],
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -1016,8 +1001,7 @@ test('edit action is disabled for non-owner', async () => {
|
||||
owners: [{ id: 999, username: 'other_user' }], // Different user
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
// Use a non-admin user to test ownership check
|
||||
const regularUser = {
|
||||
@@ -1046,8 +1030,7 @@ test('all action buttons are clickable and enabled for admin user', async () =>
|
||||
owners: [{ id: mockAdminUser.userId, username: 'admin' }],
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [virtualDataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [virtualDataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -1082,8 +1065,7 @@ test('all action buttons are clickable and enabled for admin user', async () =>
|
||||
});
|
||||
|
||||
test('displays error when initial dataset fetch fails with 500', async () => {
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
status: 500,
|
||||
body: { message: 'Internal Server Error' },
|
||||
});
|
||||
@@ -1104,8 +1086,7 @@ test('displays error when initial dataset fetch fails with 500', async () => {
|
||||
});
|
||||
|
||||
test('displays error when initial dataset fetch fails with 403 permission denied', async () => {
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
status: 403,
|
||||
body: { message: 'Access Denied' },
|
||||
});
|
||||
@@ -1119,9 +1100,9 @@ test('displays error when initial dataset fetch fails with 403 permission denied
|
||||
expect(mockAddDangerToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify toast message contains the 403-specific "Access Denied" text
|
||||
// Verify toast message contains the generic error text
|
||||
const toastMessage = String(mockAddDangerToast.mock.calls[0][0]);
|
||||
expect(toastMessage).toContain('Access Denied');
|
||||
expect(toastMessage).toContain('An error occurred while fetching datasets');
|
||||
|
||||
// No dataset names from mockDatasets should appear in the document
|
||||
mockDatasets.forEach(dataset => {
|
||||
@@ -1373,7 +1354,7 @@ test('sort order persists after deleting a dataset', async () => {
|
||||
|
||||
// Record initial API calls count
|
||||
const initialCalls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Click Name header to sort
|
||||
@@ -1381,12 +1362,16 @@ test('sort order persists after deleting a dataset', async () => {
|
||||
|
||||
// Wait for new API call with sort parameter
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(initialCalls);
|
||||
});
|
||||
|
||||
// Record the sort parameter from the API call after sorting
|
||||
const callsAfterSort = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const callsAfterSort = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
const sortedUrl = callsAfterSort[callsAfterSort.length - 1].url;
|
||||
expect(sortedUrl).toMatch(/order_column|sort/);
|
||||
|
||||
@@ -1406,7 +1391,7 @@ test('sort order persists after deleting a dataset', async () => {
|
||||
|
||||
// Record call count before delete to track refetch
|
||||
const callsBeforeDelete = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
const confirmButton = within(modal)
|
||||
@@ -1427,7 +1412,7 @@ test('sort order persists after deleting a dataset', async () => {
|
||||
// Wait for list refetch to complete (prevents async cleanup error)
|
||||
await waitFor(() => {
|
||||
const currentCalls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
expect(currentCalls).toBeGreaterThan(callsBeforeDelete);
|
||||
});
|
||||
@@ -1452,8 +1437,7 @@ test('sort order persists after deleting a dataset', async () => {
|
||||
// test. Component tests here focus on individual behaviors.
|
||||
|
||||
test('bulk selection clears when filter changes', async () => {
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: mockDatasets,
|
||||
count: mockDatasets.length,
|
||||
});
|
||||
@@ -1505,7 +1489,7 @@ test('bulk selection clears when filter changes', async () => {
|
||||
|
||||
// Record API call count before filter
|
||||
const beforeFilterCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Wait for filter combobox to be ready before applying filter
|
||||
@@ -1516,13 +1500,15 @@ test('bulk selection clears when filter changes', async () => {
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(beforeFilterCallCount);
|
||||
});
|
||||
|
||||
// Verify filter was applied by decoding URL payload
|
||||
const urlAfterFilter = fetchMock.callHistory
|
||||
.calls(API_ENDPOINTS.DATASETS)
|
||||
.calls(API_ENDPOINTS.DATASOURCE_COMBINED)
|
||||
.at(-1)?.url;
|
||||
const risonAfterFilter = new URL(
|
||||
urlAfterFilter!,
|
||||
@@ -1557,7 +1543,7 @@ test('type filter API call includes correct filter parameter', async () => {
|
||||
|
||||
// Snapshot call count before filter
|
||||
const callsBeforeFilter = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Apply Type filter
|
||||
@@ -1565,12 +1551,16 @@ test('type filter API call includes correct filter parameter', async () => {
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(callsBeforeFilter);
|
||||
});
|
||||
|
||||
// Verify the latest API call includes the Type filter
|
||||
const url = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS).at(-1)?.url;
|
||||
const url = fetchMock.callHistory
|
||||
.calls(API_ENDPOINTS.DATASOURCE_COMBINED)
|
||||
.at(-1)?.url;
|
||||
expect(url).toContain('filters');
|
||||
|
||||
// searchParams.get() already URL-decodes, so pass directly to rison.decode
|
||||
@@ -1603,7 +1593,7 @@ test('type filter persists after duplicating a dataset', async () => {
|
||||
|
||||
// Snapshot call count before filter
|
||||
const callsBeforeFilter = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Apply Type filter
|
||||
@@ -1611,13 +1601,15 @@ test('type filter persists after duplicating a dataset', async () => {
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(callsBeforeFilter);
|
||||
});
|
||||
|
||||
// Verify filter is present by checking the latest API call
|
||||
const urlAfterFilter = fetchMock.callHistory
|
||||
.calls(API_ENDPOINTS.DATASETS)
|
||||
.calls(API_ENDPOINTS.DATASOURCE_COMBINED)
|
||||
.at(-1)?.url;
|
||||
const risonAfterFilter = new URL(
|
||||
urlAfterFilter!,
|
||||
@@ -1637,7 +1629,7 @@ test('type filter persists after duplicating a dataset', async () => {
|
||||
|
||||
// Capture datasets API call count BEFORE any duplicate operations
|
||||
const datasetsCallCountBeforeDuplicate = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Now duplicate the dataset
|
||||
@@ -1673,14 +1665,14 @@ test('type filter persists after duplicating a dataset', async () => {
|
||||
// Wait for datasets refetch to occur (proves duplicate triggered a refresh)
|
||||
await waitFor(() => {
|
||||
const datasetsCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
expect(datasetsCallCount).toBeGreaterThan(datasetsCallCountBeforeDuplicate);
|
||||
});
|
||||
|
||||
// Verify Type filter persisted in the NEW datasets API call after duplication
|
||||
const urlAfterDuplicate = fetchMock.callHistory
|
||||
.calls(API_ENDPOINTS.DATASETS)
|
||||
.calls(API_ENDPOINTS.DATASOURCE_COMBINED)
|
||||
.at(-1)?.url;
|
||||
const risonAfterDuplicate = new URL(
|
||||
urlAfterDuplicate!,
|
||||
@@ -1715,8 +1707,7 @@ test('edit action shows error toast when dataset fetch fails', async () => {
|
||||
],
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [ownedDataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [ownedDataset], count: 1 });
|
||||
|
||||
// Mock SupersetClient.get to fail for the specific dataset endpoint
|
||||
jest.spyOn(SupersetClient, 'get').mockImplementation(async request => {
|
||||
@@ -1759,8 +1750,7 @@ test('bulk export error shows toast and clears loading state', async () => {
|
||||
// Mock handleResourceExport to throw an error
|
||||
mockHandleResourceExport.mockRejectedValueOnce(new Error('Export failed'));
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [mockDatasets[0]],
|
||||
count: 1,
|
||||
});
|
||||
@@ -1824,8 +1814,7 @@ test('bulk delete error shows toast without refreshing list', async () => {
|
||||
body: { message: 'Bulk delete failed' },
|
||||
});
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [mockDatasets[0]],
|
||||
count: 1,
|
||||
});
|
||||
@@ -1901,8 +1890,7 @@ test('bulk select shows "N Selected (Virtual)" for virtual-only selection', asyn
|
||||
// Use only virtual datasets
|
||||
const virtualDatasets = mockDatasets.filter(d => d.kind === 'virtual');
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: virtualDatasets,
|
||||
count: virtualDatasets.length,
|
||||
});
|
||||
@@ -1948,8 +1936,7 @@ test('bulk select shows "N Selected (Physical)" for physical-only selection', as
|
||||
// Use only physical datasets
|
||||
const physicalDatasets = mockDatasets.filter(d => d.kind === 'physical');
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: physicalDatasets,
|
||||
count: physicalDatasets.length,
|
||||
});
|
||||
@@ -1999,8 +1986,7 @@ test('bulk select shows mixed count for virtual and physical selection', async (
|
||||
mockDatasets.find(d => d.kind === 'virtual')!,
|
||||
];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: mixedDatasets,
|
||||
count: mixedDatasets.length,
|
||||
});
|
||||
@@ -2063,8 +2049,7 @@ test('delete modal shows affected dashboards with overflow for >10 items', async
|
||||
title: `Dashboard ${i + 1}`,
|
||||
}));
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
fetchMock.get(`glob:*/api/v1/dataset/${dataset.id}/related_objects*`, {
|
||||
charts: { count: 0, result: [] },
|
||||
@@ -2101,8 +2086,7 @@ test('delete modal shows affected dashboards with overflow for >10 items', async
|
||||
test('delete modal hides affected dashboards section when count is zero', async () => {
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
fetchMock.get(`glob:*/api/v1/dataset/${dataset.id}/related_objects*`, {
|
||||
charts: { count: 2, result: [{ id: 1, slice_name: 'Chart 1' }] },
|
||||
@@ -2140,8 +2124,7 @@ test('delete modal shows affected charts with overflow for >10 items', async ()
|
||||
slice_name: `Chart ${i + 1}`,
|
||||
}));
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
fetchMock.get(`glob:*/api/v1/dataset/${dataset.id}/related_objects*`, {
|
||||
charts: { count: 12, result: manyCharts },
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
mockWriteUser,
|
||||
mockExportOnlyUser,
|
||||
mockDatasets,
|
||||
API_ENDPOINTS,
|
||||
mockDatasetListEndpoints,
|
||||
} from './DatasetList.testHelpers';
|
||||
|
||||
// Increase default timeout for tests that involve multiple async operations
|
||||
@@ -238,8 +238,7 @@ test('action buttons respect user permissions', async () => {
|
||||
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -265,8 +264,7 @@ test('read-only user sees no delete or duplicate buttons in row', async () => {
|
||||
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockReadOnlyUser);
|
||||
|
||||
@@ -301,8 +299,7 @@ test('write user sees edit, delete, and export actions', async () => {
|
||||
owners: [{ id: mockWriteUser.userId, username: 'writeuser' }],
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockWriteUser);
|
||||
|
||||
@@ -337,8 +334,7 @@ test('export-only user has no Actions column (no write/duplicate permissions)',
|
||||
|
||||
const dataset = mockDatasets[0];
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockExportOnlyUser);
|
||||
|
||||
@@ -371,8 +367,7 @@ test('user with can_duplicate sees duplicate button only for virtual datasets',
|
||||
const physicalDataset = mockDatasets[0]; // kind: 'physical'
|
||||
const virtualDataset = mockDatasets[1]; // kind: 'virtual'
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [physicalDataset, virtualDataset],
|
||||
count: 2,
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
mockExportOnlyUser,
|
||||
mockDatasets,
|
||||
mockApiError403,
|
||||
mockDatasetListEndpoints,
|
||||
API_ENDPOINTS,
|
||||
RisonFilter,
|
||||
} from './DatasetList.testHelpers';
|
||||
@@ -68,13 +69,17 @@ test('shows loading state during initial data fetch', () => {
|
||||
// Use fake timers to avoid leaving real timers running after test
|
||||
jest.useFakeTimers();
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
new Promise(resolve =>
|
||||
setTimeout(() => resolve({ result: [], count: 0 }), 10000),
|
||||
),
|
||||
const delayedResponse = new Promise(resolve =>
|
||||
setTimeout(() => resolve({ result: [], count: 0 }), 10000),
|
||||
);
|
||||
fetchMock.removeRoutes({
|
||||
names: [
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
],
|
||||
});
|
||||
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, delayedResponse);
|
||||
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, delayedResponse);
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -87,13 +92,17 @@ test('maintains component structure during loading', () => {
|
||||
// Use fake timers to avoid leaving real timers running after test
|
||||
jest.useFakeTimers();
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
new Promise(resolve =>
|
||||
setTimeout(() => resolve({ result: [], count: 0 }), 10000),
|
||||
),
|
||||
const delayedResponse = new Promise(resolve =>
|
||||
setTimeout(() => resolve({ result: [], count: 0 }), 10000),
|
||||
);
|
||||
fetchMock.removeRoutes({
|
||||
names: [
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
],
|
||||
});
|
||||
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, delayedResponse);
|
||||
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, delayedResponse);
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -214,8 +223,7 @@ test('handles datasets with missing fields and renders gracefully', async () =>
|
||||
sql: null,
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetWithMissingFields],
|
||||
count: 1,
|
||||
});
|
||||
@@ -241,8 +249,7 @@ test('handles datasets with missing fields and renders gracefully', async () =>
|
||||
});
|
||||
|
||||
test('handles empty results (shows empty state)', async () => {
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [], count: 0 });
|
||||
mockDatasetListEndpoints({ result: [], count: 0 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
@@ -254,7 +261,9 @@ test('makes correct initial API call on load', async () => {
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -263,7 +272,9 @@ test('API call includes correct page size', async () => {
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const { url } = calls[0];
|
||||
expect(url).toContain('page_size');
|
||||
@@ -278,7 +289,7 @@ test('typing in name filter updates input value and triggers API with decoded se
|
||||
|
||||
// Record initial API calls
|
||||
const initialCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Type in search box and press Enter to trigger search
|
||||
@@ -292,7 +303,9 @@ test('typing in name filter updates input value and triggers API with decoded se
|
||||
// Wait for API call after Enter key press
|
||||
await waitFor(
|
||||
() => {
|
||||
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const calls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(initialCallCount);
|
||||
|
||||
// Get latest API call
|
||||
@@ -346,8 +359,7 @@ test('toggling bulk select mode shows checkboxes', async () => {
|
||||
}, 30000);
|
||||
|
||||
test('handles 500 error on initial load without crashing', async () => {
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
throws: new Error('Internal Server Error'),
|
||||
});
|
||||
|
||||
@@ -385,8 +397,7 @@ test('handles 403 error on _info endpoint and disables create actions', async ()
|
||||
});
|
||||
|
||||
test('handles network timeout without crashing', async () => {
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
throws: new Error('Network timeout'),
|
||||
});
|
||||
|
||||
@@ -414,7 +425,9 @@ test('component requires explicit mocks for all API endpoints', async () => {
|
||||
await waitForDatasetsPageReady();
|
||||
|
||||
// Verify that critical endpoints were called and had mocks available
|
||||
const newDatasetsCalls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
|
||||
const newDatasetsCalls = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
);
|
||||
const newInfoCalls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS_INFO);
|
||||
|
||||
// These should have been called during render
|
||||
@@ -446,8 +459,7 @@ test('renders datasets with certification data', async () => {
|
||||
}),
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [certifiedDataset],
|
||||
count: 1,
|
||||
});
|
||||
@@ -474,8 +486,7 @@ test('displays datasets with warning_markdown', async () => {
|
||||
}),
|
||||
};
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetWithWarning],
|
||||
count: 1,
|
||||
});
|
||||
@@ -496,8 +507,7 @@ test('displays datasets with warning_markdown', async () => {
|
||||
test('displays dataset with multiple owners', async () => {
|
||||
const datasetWithOwners = mockDatasets[1]; // Has 2 owners: Jane Smith, Bob Jones
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetWithOwners],
|
||||
count: 1,
|
||||
});
|
||||
@@ -518,8 +528,7 @@ test('displays dataset with multiple owners', async () => {
|
||||
test('displays ModifiedInfo with humanized date', async () => {
|
||||
const datasetWithModified = mockDatasets[0]; // changed_by_name: 'John Doe', changed_on: '1 day ago'
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
mockDatasetListEndpoints({
|
||||
result: [datasetWithModified],
|
||||
count: 1,
|
||||
});
|
||||
@@ -541,8 +550,7 @@ test('displays ModifiedInfo with humanized date', async () => {
|
||||
test('dataset name links to Explore with correct explore_url', async () => {
|
||||
const dataset = mockDatasets[0]; // explore_url: '/explore/?datasource=1__table'
|
||||
|
||||
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
|
||||
@@ -318,6 +318,7 @@ export const mockApiError404 = {
|
||||
export const API_ENDPOINTS = {
|
||||
DATASETS_INFO: 'glob:*/api/v1/dataset/_info*',
|
||||
DATASETS: 'glob:*/api/v1/dataset/?*',
|
||||
DATASOURCE_COMBINED: 'glob:*/api/v1/datasource/?*',
|
||||
DATASET_GET: 'glob:*/api/v1/dataset/[0-9]*',
|
||||
DATASET_RELATED_OBJECTS: 'glob:*/api/v1/dataset/*/related_objects*',
|
||||
DATASET_DELETE: 'glob:*/api/v1/dataset/[0-9]*',
|
||||
@@ -499,6 +500,24 @@ export const assertOnlyExpectedCalls = (expectedEndpoints: string[]) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to mock the dataset list endpoints.
|
||||
* The component fetches from /api/v1/datasource/ (combined endpoint).
|
||||
* Some tests also need the legacy /api/v1/dataset/ endpoint for
|
||||
* other operations (delete, bulk delete) that still use it.
|
||||
*/
|
||||
export const mockDatasetListEndpoints = (response: Record<string, unknown>) => {
|
||||
fetchMock.removeRoutes({
|
||||
names: [API_ENDPOINTS.DATASETS, API_ENDPOINTS.DATASOURCE_COMBINED],
|
||||
});
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, response, {
|
||||
name: API_ENDPOINTS.DATASETS,
|
||||
});
|
||||
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, response, {
|
||||
name: API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
});
|
||||
};
|
||||
|
||||
// MSW setup using fetch-mock (following ChartList pattern)
|
||||
// Routes are named using the API_ENDPOINTS constant values so they can be
|
||||
// removed by name using removeRoutes({ names: [API_ENDPOINTS.X] })
|
||||
@@ -511,11 +530,10 @@ export const setupMocks = () => {
|
||||
{ name: API_ENDPOINTS.DATASETS_INFO },
|
||||
);
|
||||
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.DATASETS,
|
||||
{ result: mockDatasets, count: mockDatasets.length },
|
||||
{ name: API_ENDPOINTS.DATASETS },
|
||||
);
|
||||
mockDatasetListEndpoints({
|
||||
result: mockDatasets,
|
||||
count: mockDatasets.length,
|
||||
});
|
||||
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.DATASET_FAVORITE_STATUS,
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry, SupersetClient } from '@superset-ui/core';
|
||||
import {
|
||||
getExtensionsRegistry,
|
||||
SupersetClient,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
} from '@superset-ui/core';
|
||||
import { styled, useTheme, css } from '@apache-superset/core/theme';
|
||||
import { FunctionComponent, useState, useMemo, useCallback, Key } from 'react';
|
||||
import type { CellProps } from 'react-table';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
@@ -41,8 +47,9 @@ import {
|
||||
Loading,
|
||||
List,
|
||||
} from '@superset-ui/core/components';
|
||||
import { DatasourceModal, GenericLink } from 'src/components';
|
||||
import {
|
||||
DatasourceModal,
|
||||
GenericLink,
|
||||
FacePile,
|
||||
ImportModal as ImportModelsModal,
|
||||
ModifiedInfo,
|
||||
@@ -50,6 +57,7 @@ import {
|
||||
ListViewFilterOperator as FilterOperator,
|
||||
type ListViewProps,
|
||||
type ListViewFilters,
|
||||
type ListViewFetchDataConfig,
|
||||
} from 'src/components';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import handleResourceExport from 'src/utils/export';
|
||||
@@ -67,9 +75,12 @@ import {
|
||||
CONFIRM_OVERWRITE_MESSAGE,
|
||||
} from 'src/features/datasets/constants';
|
||||
import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal';
|
||||
import type DatasetType from 'src/types/Dataset';
|
||||
import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
import type { BootstrapData } from 'src/types/bootstrapTypes';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
const DatasetDeleteRelatedExtension = extensionsRegistry.get(
|
||||
@@ -115,22 +126,28 @@ const Actions = styled.div`
|
||||
|
||||
type Dataset = {
|
||||
changed_by_name: string;
|
||||
changed_by: string;
|
||||
changed_by: Owner;
|
||||
changed_on_delta_humanized: string;
|
||||
database: {
|
||||
id: string;
|
||||
database_name: string;
|
||||
};
|
||||
kind: string;
|
||||
} | null;
|
||||
kind: 'physical' | 'virtual' | 'semantic_view';
|
||||
source_type?: 'database' | 'semantic_layer';
|
||||
explore_url: string;
|
||||
id: number;
|
||||
owners: Array<Owner>;
|
||||
schema: string;
|
||||
schema: string | null;
|
||||
table_name: string;
|
||||
description?: string | null;
|
||||
cache_timeout?: number | null;
|
||||
extra?: string | Record<string, any> | null;
|
||||
sql?: string | null;
|
||||
};
|
||||
|
||||
interface VirtualDataset extends Dataset {
|
||||
extra: Record<string, any>;
|
||||
kind: 'virtual';
|
||||
extra: string | Record<string, any>;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
@@ -152,18 +169,86 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
const history = useHistory();
|
||||
const theme = useTheme();
|
||||
const {
|
||||
state: {
|
||||
loading,
|
||||
resourceCount: datasetCount,
|
||||
resourceCollection: datasets,
|
||||
bulkSelectEnabled,
|
||||
},
|
||||
state: { bulkSelectEnabled },
|
||||
hasPerm,
|
||||
fetchData,
|
||||
toggleBulkSelect,
|
||||
refreshData,
|
||||
} = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
|
||||
|
||||
// Combined endpoint state
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [datasetCount, setDatasetCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastFetchConfig, setLastFetchConfig] =
|
||||
useState<ListViewFetchDataConfig | null>(null);
|
||||
|
||||
const fetchData = useCallback(
|
||||
(config: ListViewFetchDataConfig) => {
|
||||
setLastFetchConfig(config);
|
||||
setLoading(true);
|
||||
const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
|
||||
|
||||
// Separate source_type filter from other filters
|
||||
const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
|
||||
|
||||
const otherFilters = filterValues
|
||||
.filter(f => f.id !== 'source_type')
|
||||
.filter(
|
||||
({ value }) => value !== '' && value !== null && value !== undefined,
|
||||
)
|
||||
.map(({ id, operator: opr, value }) => ({
|
||||
col: id,
|
||||
opr,
|
||||
value:
|
||||
value && typeof value === 'object' && 'value' in value
|
||||
? value.value
|
||||
: value,
|
||||
}));
|
||||
|
||||
// Add source_type filter for the combined endpoint
|
||||
const sourceTypeValue =
|
||||
sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object'
|
||||
? (sourceTypeFilter.value as { value: string }).value
|
||||
: (sourceTypeFilter?.value as string | undefined);
|
||||
if (sourceTypeValue) {
|
||||
otherFilters.push({
|
||||
col: 'source_type',
|
||||
opr: 'eq',
|
||||
value: sourceTypeValue,
|
||||
});
|
||||
}
|
||||
|
||||
const queryParams = rison.encode_uri({
|
||||
order_column: sortBy[0].id,
|
||||
order_direction: sortBy[0].desc ? 'desc' : 'asc',
|
||||
page: pageIndex,
|
||||
page_size: pageSize,
|
||||
...(otherFilters.length ? { filters: otherFilters } : {}),
|
||||
});
|
||||
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/datasource/?q=${queryParams}`,
|
||||
})
|
||||
.then(({ json = {} }) => {
|
||||
setDatasets(json.result);
|
||||
setDatasetCount(json.count);
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(t('An error occurred while fetching datasets'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const refreshData = useCallback(() => {
|
||||
if (lastFetchConfig) {
|
||||
return fetchData(lastFetchConfig);
|
||||
}
|
||||
return undefined;
|
||||
}, [lastFetchConfig, fetchData]);
|
||||
|
||||
const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState<
|
||||
| (Dataset & {
|
||||
charts: any;
|
||||
@@ -178,6 +263,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
const [datasetCurrentlyDuplicating, setDatasetCurrentlyDuplicating] =
|
||||
useState<VirtualDataset | null>(null);
|
||||
|
||||
const [svCurrentlyEditing, setSvCurrentlyEditing] = useState<Dataset | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [importingDataset, showImportModal] = useState<boolean>(false);
|
||||
const [passwordFields, setPasswordFields] = useState<string[]>([]);
|
||||
const [preparingExport, setPreparingExport] = useState<boolean>(false);
|
||||
@@ -192,11 +281,28 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
setSSHTunnelPrivateKeyPasswordFields,
|
||||
] = useState<string[]>([]);
|
||||
|
||||
const PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET = useSelector<any, boolean>(
|
||||
const PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET = useSelector<
|
||||
BootstrapData,
|
||||
boolean
|
||||
>(
|
||||
state =>
|
||||
state.common?.conf?.PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET || false,
|
||||
);
|
||||
|
||||
const currentSourceFilter = useMemo(() => {
|
||||
const sourceTypeFilter = lastFetchConfig?.filters.find(
|
||||
filter => filter.id === 'source_type',
|
||||
);
|
||||
if (
|
||||
sourceTypeFilter?.value &&
|
||||
typeof sourceTypeFilter.value === 'object' &&
|
||||
'value' in sourceTypeFilter.value
|
||||
) {
|
||||
return sourceTypeFilter.value.value as string;
|
||||
}
|
||||
return (sourceTypeFilter?.value as string | undefined) ?? '';
|
||||
}, [lastFetchConfig]);
|
||||
|
||||
const openDatasetImportModal = () => {
|
||||
showImportModal(true);
|
||||
};
|
||||
@@ -288,7 +394,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
await handleResourceExport('dataset', ids, () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected datasets'));
|
||||
}
|
||||
@@ -315,7 +421,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
explore_url: exploreURL,
|
||||
},
|
||||
},
|
||||
}: any) => {
|
||||
}: CellProps<Dataset>) => {
|
||||
let titleLink: JSX.Element;
|
||||
if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) {
|
||||
titleLink = (
|
||||
@@ -331,7 +437,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsedExtra = JSON.parse(extra);
|
||||
const parsedExtra =
|
||||
typeof extra === 'string'
|
||||
? JSON.parse(extra)
|
||||
: (extra as Record<string, any> | null);
|
||||
return (
|
||||
<FlexRowContainer>
|
||||
{parsedExtra?.certification && (
|
||||
@@ -364,7 +473,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
row: {
|
||||
original: { kind },
|
||||
},
|
||||
}: any) => <DatasetTypeLabel datasetType={kind} />,
|
||||
}: CellProps<Dataset>) => <DatasetTypeLabel datasetType={kind} />,
|
||||
Header: t('Type'),
|
||||
accessor: 'kind',
|
||||
disableSortBy: true,
|
||||
@@ -372,12 +481,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
id: 'kind',
|
||||
},
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { database },
|
||||
},
|
||||
}: CellProps<Dataset>) => database?.database_name || '-',
|
||||
Header: t('Database'),
|
||||
accessor: 'database.database_name',
|
||||
size: 'xl',
|
||||
id: 'database.database_name',
|
||||
},
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { schema },
|
||||
},
|
||||
}: CellProps<Dataset>) => schema || '-',
|
||||
Header: t('Schema'),
|
||||
accessor: 'schema',
|
||||
size: 'lg',
|
||||
@@ -394,7 +513,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
row: {
|
||||
original: { owners = [] },
|
||||
},
|
||||
}: any) => <FacePile users={owners} />,
|
||||
}: CellProps<Dataset>) => <FacePile users={owners} />,
|
||||
Header: t('Owners'),
|
||||
id: 'owners',
|
||||
disableSortBy: true,
|
||||
@@ -408,7 +527,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
changed_by: changedBy,
|
||||
},
|
||||
},
|
||||
}: any) => <ModifiedInfo date={changedOn} user={changedBy} />,
|
||||
}: CellProps<Dataset>) => (
|
||||
<ModifiedInfo date={changedOn} user={changedBy} />
|
||||
),
|
||||
Header: t('Last modified'),
|
||||
accessor: 'changed_on_delta_humanized',
|
||||
size: 'xl',
|
||||
@@ -421,16 +542,52 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
id: 'sql',
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: any) => {
|
||||
// Verify owner or isAdmin
|
||||
accessor: 'source_type',
|
||||
hidden: true,
|
||||
disableSortBy: true,
|
||||
id: 'source_type',
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Dataset>) => {
|
||||
const isSemanticView = original.kind === 'semantic_view';
|
||||
|
||||
// Semantic view: only show edit button
|
||||
if (isSemanticView) {
|
||||
if (!canEdit) return null;
|
||||
return (
|
||||
<Actions className="actions">
|
||||
<Tooltip
|
||||
id="edit-action-tooltip"
|
||||
title={t('Edit')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={() => setSvCurrentlyEditing(original)}
|
||||
>
|
||||
<Icons.EditOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
|
||||
// Dataset: full set of actions
|
||||
const allowEdit =
|
||||
original.owners.map((o: Owner) => o.id).includes(user.userId) ||
|
||||
isUserAdmin(user);
|
||||
original.owners
|
||||
.map((o: Owner) => o.id)
|
||||
.includes(Number(user.userId)) || isUserAdmin(user);
|
||||
|
||||
const handleEdit = () => openDatasetEditModal(original);
|
||||
const handleDelete = () => openDatasetDeleteModal(original);
|
||||
const handleExport = () => handleBulkDatasetExport([original]);
|
||||
const handleDuplicate = () => openDatasetDuplicateModal(original);
|
||||
const handleDuplicate = () => {
|
||||
if (original.kind === 'virtual' && original.sql) {
|
||||
openDatasetDuplicateModal(original as VirtualDataset);
|
||||
}
|
||||
};
|
||||
if (!canEdit && !canDelete && !canExport && !canDuplicate) {
|
||||
return null;
|
||||
}
|
||||
@@ -536,6 +693,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
|
||||
const filterTypes: ListViewFilters = useMemo(
|
||||
() => [
|
||||
...(isFeatureEnabled(FeatureFlag.SemanticLayers)
|
||||
? [
|
||||
{
|
||||
Header: t('Source'),
|
||||
key: 'source_type',
|
||||
id: 'source_type',
|
||||
input: 'select' as const,
|
||||
operator: FilterOperator.Equals,
|
||||
unfilteredLabel: t('All'),
|
||||
selects: [
|
||||
{ label: t('Database'), value: 'database' },
|
||||
{ label: t('Semantic Layer'), value: 'semantic_layer' },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
Header: t('Name'),
|
||||
key: 'search',
|
||||
@@ -543,18 +716,42 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
input: 'search',
|
||||
operator: FilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Type'),
|
||||
key: 'sql',
|
||||
id: 'sql',
|
||||
input: 'select',
|
||||
operator: FilterOperator.DatasetIsNullOrEmpty,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: t('Virtual'), value: false },
|
||||
{ label: t('Physical'), value: true },
|
||||
],
|
||||
},
|
||||
...(isFeatureEnabled(FeatureFlag.SemanticLayers)
|
||||
? [
|
||||
{
|
||||
Header: t('Type'),
|
||||
key: 'sql',
|
||||
id: 'sql',
|
||||
input: 'select' as const,
|
||||
operator: FilterOperator.DatasetIsNullOrEmpty,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
...(currentSourceFilter !== 'semantic_layer'
|
||||
? [
|
||||
{ label: t('Physical'), value: true },
|
||||
{ label: t('Virtual'), value: false },
|
||||
]
|
||||
: []),
|
||||
...(currentSourceFilter !== 'database'
|
||||
? [{ label: t('Semantic View'), value: 'semantic_view' }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
Header: t('Type'),
|
||||
key: 'sql',
|
||||
id: 'sql',
|
||||
input: 'select' as const,
|
||||
operator: FilterOperator.DatasetIsNullOrEmpty,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: t('Physical'), value: true },
|
||||
{ label: t('Virtual'), value: false },
|
||||
],
|
||||
},
|
||||
]),
|
||||
{
|
||||
Header: t('Database'),
|
||||
key: 'database',
|
||||
@@ -645,7 +842,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
|
||||
},
|
||||
],
|
||||
[user],
|
||||
[user, currentSourceFilter],
|
||||
);
|
||||
|
||||
const menuData: SubMenuProps = {
|
||||
@@ -893,10 +1090,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
/>
|
||||
)}
|
||||
<DuplicateDatasetModal
|
||||
dataset={datasetCurrentlyDuplicating}
|
||||
dataset={datasetCurrentlyDuplicating as DatasetType | null}
|
||||
onHide={closeDatasetDuplicateModal}
|
||||
onDuplicate={handleDatasetDuplicate}
|
||||
/>
|
||||
<SemanticViewEditModal
|
||||
show={!!svCurrentlyEditing}
|
||||
onHide={() => setSvCurrentlyEditing(null)}
|
||||
onSave={refreshData}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
semanticView={svCurrentlyEditing}
|
||||
/>
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t(
|
||||
|
||||
@@ -25,11 +25,18 @@ export default interface Dataset {
|
||||
database: {
|
||||
id: string;
|
||||
database_name: string;
|
||||
};
|
||||
} | null;
|
||||
kind: string;
|
||||
source_type?: 'database' | 'semantic_layer';
|
||||
explore_url: string;
|
||||
id: number;
|
||||
owners: Array<Owner>;
|
||||
schema: string;
|
||||
schema: string | null;
|
||||
catalog?: string | null;
|
||||
table_name: string;
|
||||
description?: string | null;
|
||||
cache_timeout?: number | null;
|
||||
default_endpoint?: string | null;
|
||||
is_sqllab_view?: boolean;
|
||||
is_managed_externally?: boolean;
|
||||
}
|
||||
|
||||
@@ -299,8 +299,9 @@ class ChartDataRestApi(ChartRestApi):
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
|
||||
f".data_from_cache",
|
||||
action=lambda self, *args, **kwargs: (
|
||||
f"{self.__class__.__name__}.data_from_cache"
|
||||
),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def data_from_cache(self, cache_key: str) -> Response:
|
||||
@@ -405,7 +406,13 @@ class ChartDataRestApi(ChartRestApi):
|
||||
|
||||
if result_format in ChartDataResultFormat.table_like():
|
||||
# Verify user has permission to export file
|
||||
if not security_manager.can_access("can_csv", "Superset"):
|
||||
if is_feature_enabled("GRANULAR_EXPORT_CONTROLS"):
|
||||
has_export_perm = security_manager.can_access(
|
||||
"can_export_data", "Superset"
|
||||
)
|
||||
else:
|
||||
has_export_perm = security_manager.can_access("can_csv", "Superset")
|
||||
if not has_export_perm:
|
||||
return self.response_403()
|
||||
|
||||
if not result["queries"]:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
"""Command for the combined dataset + semantic view list endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import union_all
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.daos.datasource import DatasourceDAO
|
||||
from superset.datasource.schemas import DatasetListSchema, SemanticViewListSchema
|
||||
from superset.semantic_layers.models import SemanticView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_dataset_schema = DatasetListSchema()
|
||||
_semantic_view_schema = SemanticViewListSchema()
|
||||
|
||||
|
||||
class GetCombinedDatasourceListCommand(BaseCommand):
|
||||
"""
|
||||
Fetch and serialize a paginated, combined list of datasets and semantic views.
|
||||
|
||||
Callers are responsible for checking access permissions before constructing
|
||||
this command and for passing the appropriate ``can_read_*`` flags.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
args: dict[str, Any],
|
||||
can_read_datasets: bool,
|
||||
can_read_semantic_views: bool,
|
||||
) -> None:
|
||||
self._args = args
|
||||
self._can_read_datasets = can_read_datasets
|
||||
self._can_read_semantic_views = can_read_semantic_views
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
|
||||
page = self._args.get("page", 0)
|
||||
page_size = self._args.get("page_size", 25)
|
||||
order_column = self._args.get("order_column", "changed_on")
|
||||
order_direction = self._args.get("order_direction", "desc")
|
||||
filters = self._args.get("filters", [])
|
||||
|
||||
source_type, name_filter, sql_filter, type_filter = self._parse_filters(filters)
|
||||
source_type = self._resolve_source_type(source_type, sql_filter, type_filter)
|
||||
|
||||
ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter)
|
||||
sv_q = DatasourceDAO.build_semantic_view_query(name_filter)
|
||||
|
||||
if source_type == "database":
|
||||
combined = ds_q.subquery()
|
||||
elif source_type == "semantic_layer":
|
||||
combined = sv_q.subquery()
|
||||
else:
|
||||
combined = union_all(ds_q, sv_q).subquery()
|
||||
|
||||
total_count, rows = DatasourceDAO.paginate_combined_query(
|
||||
combined, order_column, order_direction, page, page_size
|
||||
)
|
||||
|
||||
datasets_map = DatasourceDAO.fetch_datasets_by_ids(
|
||||
[r.item_id for r in rows if r.source_type == "database"]
|
||||
)
|
||||
sv_map = DatasourceDAO.fetch_semantic_views_by_ids(
|
||||
[r.item_id for r in rows if r.source_type == "semantic_layer"]
|
||||
)
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if row.source_type == "database":
|
||||
ds_obj = cast(SqlaTable | None, datasets_map.get(row.item_id))
|
||||
if ds_obj:
|
||||
result.append(_dataset_schema.dump(ds_obj))
|
||||
else:
|
||||
sv_obj = cast(SemanticView | None, sv_map.get(row.item_id))
|
||||
if sv_obj:
|
||||
result.append(_semantic_view_schema.dump(sv_obj))
|
||||
|
||||
return {"count": total_count, "result": result}
|
||||
|
||||
def validate(self) -> None:
|
||||
pass # access checks are performed by the caller (API layer)
|
||||
|
||||
def _resolve_source_type(
|
||||
self,
|
||||
source_type: str,
|
||||
sql_filter: bool | None,
|
||||
type_filter: str | None,
|
||||
) -> str:
|
||||
"""Narrow source_type based on access flags, sql filter, and type filter."""
|
||||
if not self._can_read_semantic_views:
|
||||
return "database"
|
||||
if not self._can_read_datasets:
|
||||
return "semantic_layer"
|
||||
# sql_filter (physical/virtual toggle) only applies to datasets
|
||||
if sql_filter is not None:
|
||||
return "database"
|
||||
# Explicit semantic-view type filter
|
||||
if type_filter == "semantic_view":
|
||||
return "semantic_layer"
|
||||
return source_type
|
||||
|
||||
@staticmethod
|
||||
def _parse_filters(
|
||||
filters: list[dict[str, Any]],
|
||||
) -> tuple[str, str | None, bool | None, str | None]:
|
||||
"""
|
||||
Translate raw rison filter dicts into typed query parameters.
|
||||
|
||||
Returns:
|
||||
source_type: "all" | "database" | "semantic_layer"
|
||||
name_filter: substring to match against name/table_name
|
||||
sql_filter: True → physical only, False → virtual only, None → both
|
||||
type_filter: "semantic_view" when the caller wants only semantic views
|
||||
"""
|
||||
source_type = "all"
|
||||
name_filter: str | None = None
|
||||
sql_filter: bool | None = None
|
||||
type_filter: str | None = None
|
||||
|
||||
for f in filters:
|
||||
col = f.get("col")
|
||||
opr = f.get("opr")
|
||||
value = f.get("value")
|
||||
|
||||
if col == "source_type":
|
||||
source_type = value or "all"
|
||||
elif col == "table_name" and f.get("opr") == "ct":
|
||||
name_filter = value
|
||||
elif col == "sql":
|
||||
if opr == "dataset_is_null_or_empty" and value == "semantic_view":
|
||||
type_filter = "semantic_view"
|
||||
elif opr == "dataset_is_null_or_empty" and isinstance(value, bool):
|
||||
sql_filter = value
|
||||
|
||||
return source_type, name_filter, sql_filter, type_filter
|
||||
@@ -124,7 +124,11 @@ class GetExploreCommand(BaseCommand, ABC):
|
||||
security_manager.raise_for_access(datasource=datasource)
|
||||
|
||||
viz_type = form_data.get("viz_type")
|
||||
if not viz_type and datasource and datasource.default_endpoint:
|
||||
if (
|
||||
not viz_type
|
||||
and datasource
|
||||
and getattr(datasource, "default_endpoint", None)
|
||||
):
|
||||
raise WrongEndpointError(redirect=datasource.default_endpoint)
|
||||
|
||||
form_data["datasource"] = (
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerInvalidError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateSemanticLayerCommand(BaseCommand):
|
||||
def __init__(self, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
|
||||
@transaction(
|
||||
on_error=partial(
|
||||
on_error,
|
||||
catches=(SQLAlchemyError, ValueError),
|
||||
reraise=SemanticLayerCreateFailedError,
|
||||
)
|
||||
)
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
if isinstance(self._properties.get("configuration"), dict):
|
||||
self._properties["configuration"] = json.dumps(
|
||||
self._properties["configuration"]
|
||||
)
|
||||
return SemanticLayerDAO.create(attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
sl_type = self._properties.get("type")
|
||||
if sl_type not in registry:
|
||||
raise SemanticLayerInvalidError(f"Unknown type: {sl_type}")
|
||||
|
||||
name: str = self._properties.get("name", "")
|
||||
if not SemanticLayerDAO.validate_uniqueness(name):
|
||||
raise SemanticLayerInvalidError(f"Name already exists: {name}")
|
||||
|
||||
# Validate configuration against the plugin
|
||||
cls = registry[sl_type]
|
||||
cls.from_configuration(self._properties["configuration"])
|
||||
@@ -0,0 +1,56 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerNotFoundError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO
|
||||
from superset.semantic_layers.models import SemanticLayer
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeleteSemanticLayerCommand(BaseCommand):
|
||||
def __init__(self, uuid: str):
|
||||
self._uuid = uuid
|
||||
self._model: SemanticLayer | None = None
|
||||
|
||||
@transaction(
|
||||
on_error=partial(
|
||||
on_error,
|
||||
catches=(SQLAlchemyError,),
|
||||
reraise=SemanticLayerDeleteFailedError,
|
||||
)
|
||||
)
|
||||
def run(self) -> None:
|
||||
self.validate()
|
||||
assert self._model
|
||||
SemanticLayerDAO.delete([self._model])
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
@@ -0,0 +1,68 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask_babel import lazy_gettext as _
|
||||
|
||||
from superset.commands.exceptions import (
|
||||
CommandException,
|
||||
CommandInvalidError,
|
||||
CreateFailedError,
|
||||
DeleteFailedError,
|
||||
ForbiddenError,
|
||||
UpdateFailedError,
|
||||
)
|
||||
|
||||
|
||||
class SemanticViewNotFoundError(CommandException):
|
||||
status = 404
|
||||
message = _("Semantic view does not exist")
|
||||
|
||||
|
||||
class SemanticViewForbiddenError(ForbiddenError):
|
||||
message = _("Changing this semantic view is forbidden")
|
||||
|
||||
|
||||
class SemanticViewInvalidError(CommandInvalidError):
|
||||
message = _("Semantic view parameters are invalid.")
|
||||
|
||||
|
||||
class SemanticViewUpdateFailedError(UpdateFailedError):
|
||||
message = _("Semantic view could not be updated.")
|
||||
|
||||
|
||||
class SemanticLayerNotFoundError(CommandException):
|
||||
status = 404
|
||||
message = _("Semantic layer does not exist")
|
||||
|
||||
|
||||
class SemanticLayerForbiddenError(ForbiddenError):
|
||||
message = _("Changing this semantic layer is forbidden")
|
||||
|
||||
|
||||
class SemanticLayerInvalidError(CommandInvalidError):
|
||||
message = _("Semantic layer parameters are invalid.")
|
||||
|
||||
|
||||
class SemanticLayerCreateFailedError(CreateFailedError):
|
||||
message = _("Semantic layer could not be created.")
|
||||
|
||||
|
||||
class SemanticLayerUpdateFailedError(UpdateFailedError):
|
||||
message = _("Semantic layer could not be updated.")
|
||||
|
||||
|
||||
class SemanticLayerDeleteFailedError(DeleteFailedError):
|
||||
message = _("Semantic layer could not be deleted.")
|
||||
@@ -0,0 +1,126 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, model_id: int, data: dict[str, Any]):
|
||||
self._model_id = model_id
|
||||
self._properties = data.copy()
|
||||
self._model: SemanticView | None = None
|
||||
|
||||
@transaction(
|
||||
on_error=partial(
|
||||
on_error,
|
||||
catches=(SQLAlchemyError, ValueError),
|
||||
reraise=SemanticViewUpdateFailedError,
|
||||
)
|
||||
)
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
assert self._model
|
||||
return SemanticViewDAO.update(self._model, attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = SemanticViewDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
|
||||
try:
|
||||
security_manager.raise_for_ownership(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
|
||||
name = self._properties.get("name", self._model.name)
|
||||
layer_uuid = str(self._model.semantic_layer_uuid)
|
||||
configuration = self._properties.get(
|
||||
"configuration",
|
||||
json.loads(self._model.configuration),
|
||||
)
|
||||
if not SemanticViewDAO.validate_update_uniqueness(
|
||||
view_uuid=str(self._model.uuid),
|
||||
name=name,
|
||||
layer_uuid=layer_uuid,
|
||||
configuration=configuration,
|
||||
):
|
||||
raise ValueError(
|
||||
f"A semantic view with name '{name}' and the same "
|
||||
"configuration already exists in this semantic layer."
|
||||
)
|
||||
|
||||
|
||||
class UpdateSemanticLayerCommand(BaseCommand):
|
||||
def __init__(self, uuid: str, data: dict[str, Any]):
|
||||
self._uuid = uuid
|
||||
self._properties = data.copy()
|
||||
self._model: SemanticLayer | None = None
|
||||
|
||||
@transaction(
|
||||
on_error=partial(
|
||||
on_error,
|
||||
catches=(SQLAlchemyError, ValueError),
|
||||
reraise=SemanticLayerUpdateFailedError,
|
||||
)
|
||||
)
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
assert self._model
|
||||
if isinstance(self._properties.get("configuration"), dict):
|
||||
self._properties["configuration"] = json.dumps(
|
||||
self._properties["configuration"]
|
||||
)
|
||||
return SemanticLayerDAO.update(self._model, attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
name = self._properties.get("name")
|
||||
if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
|
||||
raise SemanticLayerInvalidError(f"Name already exists: {name}")
|
||||
|
||||
if configuration := self._properties.get("configuration"):
|
||||
sl_type = self._model.type
|
||||
cls = registry[sl_type]
|
||||
cls.from_configuration(configuration)
|
||||
@@ -562,6 +562,13 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
# in addition to relative timeshifts (e.g., "1 day ago")
|
||||
# @lifecycle: development
|
||||
"DATE_RANGE_TIMESHIFTS_ENABLED": False,
|
||||
# Enable granular export controls (can_export_data, can_export_image,
|
||||
# can_copy_clipboard) instead of the single can_csv permission
|
||||
# @lifecycle: development
|
||||
"GRANULAR_EXPORT_CONTROLS": False,
|
||||
# Enable semantic layers and show semantic views alongside datasets
|
||||
# @lifecycle: development
|
||||
"SEMANTIC_LAYERS": False,
|
||||
# Enables advanced data type support
|
||||
# @lifecycle: development
|
||||
"ENABLE_ADVANCED_DATA_TYPES": False,
|
||||
|
||||
@@ -108,6 +108,8 @@ from superset.sql.parse import Table
|
||||
from superset.superset_typing import (
|
||||
AdhocColumn,
|
||||
AdhocMetric,
|
||||
DatasetColumnData,
|
||||
DatasetMetricData,
|
||||
ExplorableData,
|
||||
Metric,
|
||||
QueryObjectDict,
|
||||
@@ -464,8 +466,8 @@ class BaseDatasource(
|
||||
# sqla-specific
|
||||
"sql": self.sql,
|
||||
# one to many
|
||||
"columns": [o.data for o in self.columns],
|
||||
"metrics": [o.data for o in self.metrics],
|
||||
"columns": [cast(DatasetColumnData, o.data) for o in self.columns],
|
||||
"metrics": [cast(DatasetMetricData, o.data) for o in self.metrics],
|
||||
"folders": self.folders,
|
||||
# TODO deprecate, move logic to JS
|
||||
"order_by_choices": self.order_by_choices,
|
||||
|
||||
@@ -229,6 +229,40 @@ def inject_model_session_implementation() -> None:
|
||||
core_models_module.get_session = get_session
|
||||
|
||||
|
||||
def inject_semantic_layer_implementations() -> None:
|
||||
"""
|
||||
Replace abstract semantic layer decorator in
|
||||
superset_core.semantic_layers.decorators with a concrete implementation
|
||||
that registers classes in the contributions registry.
|
||||
"""
|
||||
import superset_core.semantic_layers.decorators as core_sl_module
|
||||
|
||||
import superset.extensions.context as context_module
|
||||
from superset.semantic_layers.registry import registry
|
||||
|
||||
def semantic_layer_impl(
|
||||
id: str,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
) -> Callable[[Any], Any]:
|
||||
def decorator(cls: Any) -> Any:
|
||||
if context := context_module.get_current_extension_context():
|
||||
manifest = context.manifest
|
||||
prefixed_id = f"extensions.{manifest.publisher}.{manifest.name}.{id}"
|
||||
else:
|
||||
prefixed_id = id
|
||||
|
||||
cls.name = name
|
||||
cls.description = description
|
||||
cls._semantic_layer_id = prefixed_id
|
||||
registry[prefixed_id] = cls
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
core_sl_module.semantic_layer = semantic_layer_impl # type: ignore[assignment]
|
||||
|
||||
|
||||
def initialize_core_api_dependencies() -> None:
|
||||
"""
|
||||
Initialize all dependency injections for the superset-core API.
|
||||
@@ -242,3 +276,4 @@ def initialize_core_api_dependencies() -> None:
|
||||
inject_query_implementations()
|
||||
inject_task_implementations()
|
||||
inject_rest_api_implementations()
|
||||
inject_semantic_layer_implementations()
|
||||
|
||||
@@ -251,23 +251,28 @@ def initialize_core_mcp_dependencies() -> None:
|
||||
|
||||
Also imports MCP service app to register all host tools BEFORE extension loading.
|
||||
"""
|
||||
import superset_core.mcp.decorators
|
||||
|
||||
try:
|
||||
# Replace the abstract decorators with concrete implementations
|
||||
from fastmcp.tools import Tool # noqa: F401
|
||||
except ImportError:
|
||||
logger.info(
|
||||
"fastmcp is not installed, skipping MCP initialization. "
|
||||
"Install it with: pip install 'apache-superset[fastmcp]'"
|
||||
)
|
||||
return
|
||||
|
||||
import superset_core.mcp.decorators
|
||||
# Replace the abstract decorators with concrete implementations
|
||||
superset_core.mcp.decorators.tool = create_tool_decorator
|
||||
superset_core.mcp.decorators.prompt = create_prompt_decorator
|
||||
|
||||
superset_core.mcp.decorators.tool = create_tool_decorator
|
||||
superset_core.mcp.decorators.prompt = create_prompt_decorator
|
||||
|
||||
logger.info("MCP dependency injection initialized successfully")
|
||||
logger.info("MCP dependency injection initialized successfully")
|
||||
|
||||
try:
|
||||
# Import MCP service app to register host tools BEFORE extension loading
|
||||
# This prevents host tools from being registered during extension context
|
||||
|
||||
from superset.mcp_service import app # noqa: F401
|
||||
|
||||
logger.info("MCP service app imported - host tools registered")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize MCP dependencies: %s", e)
|
||||
raise
|
||||
logger.error("Failed to register MCP host tools: %s", e)
|
||||
|
||||
+127
-3
@@ -17,9 +17,14 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Union
|
||||
from typing import Any, Union
|
||||
|
||||
from superset import db
|
||||
from sqlalchemy import and_, func, literal, or_, select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql import Select
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.connectors.sqla import models as sqla_models
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.daos.exceptions import (
|
||||
@@ -28,11 +33,17 @@ from superset.daos.exceptions import (
|
||||
DatasourceValueIsIncorrect,
|
||||
)
|
||||
from superset.models.sql_lab import Query, SavedQuery
|
||||
from superset.semantic_layers.models import SemanticView
|
||||
from superset.utils.core import DatasourceType
|
||||
from superset.utils.filters import get_dataset_access_filters
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Datasource = Union[SqlaTable, Query, SavedQuery]
|
||||
Datasource = Union[SqlaTable, Query, SavedQuery, SemanticView]
|
||||
|
||||
|
||||
def _escape_ilike_fragment(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
class DatasourceDAO(BaseDAO[Datasource]):
|
||||
@@ -40,6 +51,7 @@ class DatasourceDAO(BaseDAO[Datasource]):
|
||||
DatasourceType.TABLE: SqlaTable,
|
||||
DatasourceType.QUERY: Query,
|
||||
DatasourceType.SAVEDQUERY: SavedQuery,
|
||||
DatasourceType.SEMANTIC_VIEW: SemanticView,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -78,3 +90,115 @@ class DatasourceDAO(BaseDAO[Datasource]):
|
||||
raise DatasourceNotFound()
|
||||
|
||||
return datasource
|
||||
|
||||
@staticmethod
|
||||
def build_dataset_query(
|
||||
name_filter: str | None,
|
||||
sql_filter: bool | None,
|
||||
) -> Select:
|
||||
"""Build a SELECT for datasets, applying access and content filters."""
|
||||
ds_q = select(
|
||||
SqlaTable.id.label("item_id"),
|
||||
literal("database").label("source_type"),
|
||||
SqlaTable.changed_on,
|
||||
SqlaTable.table_name,
|
||||
).select_from(SqlaTable.__table__)
|
||||
|
||||
if not security_manager.can_access_all_datasources():
|
||||
ds_q = ds_q.join(
|
||||
sqla_models.Database,
|
||||
sqla_models.Database.id == SqlaTable.database_id,
|
||||
)
|
||||
ds_q = ds_q.where(get_dataset_access_filters(SqlaTable))
|
||||
|
||||
if name_filter:
|
||||
escaped = _escape_ilike_fragment(name_filter)
|
||||
ds_q = ds_q.where(SqlaTable.table_name.ilike(f"%{escaped}%", escape="\\"))
|
||||
|
||||
if sql_filter is not None:
|
||||
if sql_filter:
|
||||
ds_q = ds_q.where(or_(SqlaTable.sql.is_(None), SqlaTable.sql == ""))
|
||||
else:
|
||||
ds_q = ds_q.where(and_(SqlaTable.sql.isnot(None), SqlaTable.sql != ""))
|
||||
|
||||
return ds_q
|
||||
|
||||
@staticmethod
|
||||
def build_semantic_view_query(name_filter: str | None) -> Select:
|
||||
"""Build a SELECT for semantic views, applying name filter."""
|
||||
sv_q = select(
|
||||
SemanticView.id.label("item_id"),
|
||||
literal("semantic_layer").label("source_type"),
|
||||
SemanticView.changed_on,
|
||||
SemanticView.name.label("table_name"),
|
||||
).select_from(SemanticView.__table__)
|
||||
|
||||
if name_filter:
|
||||
escaped = _escape_ilike_fragment(name_filter)
|
||||
sv_q = sv_q.where(SemanticView.name.ilike(f"%{escaped}%", escape="\\"))
|
||||
|
||||
return sv_q
|
||||
|
||||
@staticmethod
|
||||
def paginate_combined_query(
|
||||
combined: Any,
|
||||
order_column: str,
|
||||
order_direction: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[int, list[Any]]:
|
||||
"""Count, sort, and paginate the combined dataset/semantic-view query."""
|
||||
sort_col_map = {
|
||||
"changed_on": "changed_on",
|
||||
"changed_on_delta_humanized": "changed_on",
|
||||
"table_name": "table_name",
|
||||
}
|
||||
if order_column not in sort_col_map:
|
||||
raise ValueError(f"Invalid order column: {order_column}")
|
||||
sort_col_name = sort_col_map[order_column]
|
||||
|
||||
total_count = (
|
||||
db.session.execute(select(func.count()).select_from(combined)).scalar() or 0
|
||||
)
|
||||
|
||||
sort_col = combined.c[sort_col_name]
|
||||
ordered_col = sort_col.desc() if order_direction == "desc" else sort_col.asc()
|
||||
|
||||
rows = db.session.execute(
|
||||
select(combined.c.item_id, combined.c.source_type)
|
||||
.order_by(ordered_col)
|
||||
.offset(page * page_size)
|
||||
.limit(page_size)
|
||||
).fetchall()
|
||||
|
||||
return total_count, rows
|
||||
|
||||
@staticmethod
|
||||
def fetch_datasets_by_ids(ids: list[int]) -> dict[int, SqlaTable]:
|
||||
"""Fetch SqlaTable objects by id with relationships eager-loaded."""
|
||||
if not ids:
|
||||
return {}
|
||||
objs = (
|
||||
db.session.query(SqlaTable)
|
||||
.options(
|
||||
joinedload(SqlaTable.database),
|
||||
joinedload(SqlaTable.owners),
|
||||
joinedload(SqlaTable.changed_by),
|
||||
)
|
||||
.filter(SqlaTable.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
return {obj.id: obj for obj in objs}
|
||||
|
||||
@staticmethod
|
||||
def fetch_semantic_views_by_ids(ids: list[int]) -> dict[int, SemanticView]:
|
||||
"""Fetch SemanticView objects by id with relationships eager-loaded."""
|
||||
if not ids:
|
||||
return {}
|
||||
objs = (
|
||||
db.session.query(SemanticView)
|
||||
.options(joinedload(SemanticView.changed_by))
|
||||
.filter(SemanticView.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
return {obj.id: obj for obj in objs}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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.
|
||||
|
||||
"""DAOs for semantic layer models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import StatementError
|
||||
|
||||
from superset_core.semantic_layers.daos import (
|
||||
AbstractSemanticLayerDAO,
|
||||
AbstractSemanticViewDAO,
|
||||
)
|
||||
|
||||
from superset.extensions import db
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
class SemanticLayerDAO(AbstractSemanticLayerDAO):
|
||||
"""
|
||||
Data Access Object for SemanticLayer model.
|
||||
"""
|
||||
|
||||
model_cls = SemanticLayer
|
||||
|
||||
@staticmethod
|
||||
def find_by_uuid(uuid_str: str) -> SemanticLayer | None:
|
||||
try:
|
||||
return (
|
||||
db.session.query(SemanticLayer)
|
||||
.filter(SemanticLayer.uuid == uuid_str)
|
||||
.one_or_none()
|
||||
)
|
||||
except (ValueError, StatementError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def find_all(cls, skip_base_filter: bool = False) -> list[SemanticLayer]:
|
||||
query = db.session.query(SemanticLayer)
|
||||
query = cls._apply_base_filter(query, skip_base_filter)
|
||||
return query.all()
|
||||
|
||||
@classmethod
|
||||
def validate_uniqueness(cls, name: str) -> bool:
|
||||
"""
|
||||
Validate that semantic layer name is unique.
|
||||
|
||||
:param name: Semantic layer name
|
||||
:return: True if name is unique, False otherwise
|
||||
"""
|
||||
query = db.session.query(SemanticLayer).filter(SemanticLayer.name == name)
|
||||
return not db.session.query(query.exists()).scalar()
|
||||
|
||||
@classmethod
|
||||
def validate_update_uniqueness(cls, layer_uuid: str, name: str) -> bool:
|
||||
"""
|
||||
Validate that semantic layer name is unique for updates.
|
||||
|
||||
:param layer_uuid: UUID of the semantic layer being updated
|
||||
:param name: New name to validate
|
||||
:return: True if name is unique, False otherwise
|
||||
"""
|
||||
query = db.session.query(SemanticLayer).filter(
|
||||
SemanticLayer.name == name,
|
||||
SemanticLayer.uuid != layer_uuid,
|
||||
)
|
||||
return not db.session.query(query.exists()).scalar()
|
||||
|
||||
@classmethod
|
||||
def find_by_name(cls, name: str) -> SemanticLayer | None:
|
||||
"""
|
||||
Find semantic layer by name.
|
||||
|
||||
:param name: Semantic layer name
|
||||
:return: SemanticLayer instance or None
|
||||
"""
|
||||
return (
|
||||
db.session.query(SemanticLayer)
|
||||
.filter(SemanticLayer.name == name)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_semantic_views(cls, layer_uuid: str) -> list[SemanticView]:
|
||||
"""
|
||||
Get all semantic views for a semantic layer.
|
||||
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:return: List of SemanticView instances
|
||||
"""
|
||||
return (
|
||||
db.session.query(SemanticView)
|
||||
.filter(SemanticView.semantic_layer_uuid == layer_uuid)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class SemanticViewDAO(AbstractSemanticViewDAO):
|
||||
"""Data Access Object for SemanticView model."""
|
||||
|
||||
model_cls = SemanticView
|
||||
|
||||
@classmethod
|
||||
def validate_uniqueness(
|
||||
cls,
|
||||
name: str,
|
||||
layer_uuid: str,
|
||||
configuration: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that view is unique within a semantic layer.
|
||||
|
||||
Uniqueness is determined by name, layer, and configuration.
|
||||
The configuration column is encrypted (non-deterministic
|
||||
ciphertext), so it cannot be compared at the DB level. Instead,
|
||||
we filter by name + layer in SQL and compare decrypted
|
||||
configuration dicts in Python.
|
||||
|
||||
:param name: View name
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:param configuration: Configuration dict to compare
|
||||
:return: True if unique, False otherwise
|
||||
"""
|
||||
candidates = (
|
||||
db.session.query(SemanticView)
|
||||
.filter(
|
||||
SemanticView.name == name,
|
||||
SemanticView.semantic_layer_uuid == layer_uuid,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return not any(json.loads(c.configuration) == configuration for c in candidates)
|
||||
|
||||
@classmethod
|
||||
def validate_update_uniqueness(
|
||||
cls,
|
||||
view_uuid: str,
|
||||
name: str,
|
||||
layer_uuid: str,
|
||||
configuration: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that view is unique within a semantic layer for updates.
|
||||
|
||||
Same logic as ``validate_uniqueness`` but excludes the view
|
||||
being updated.
|
||||
|
||||
:param view_uuid: UUID of the view being updated
|
||||
:param name: New name to validate
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:param configuration: Configuration dict to compare
|
||||
:return: True if unique, False otherwise
|
||||
"""
|
||||
candidates = (
|
||||
db.session.query(SemanticView)
|
||||
.filter(
|
||||
SemanticView.name == name,
|
||||
SemanticView.semantic_layer_uuid == layer_uuid,
|
||||
SemanticView.uuid != view_uuid,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return not any(json.loads(c.configuration) == configuration for c in candidates)
|
||||
|
||||
@classmethod
|
||||
def find_by_name(cls, name: str, layer_uuid: str) -> SemanticView | None:
|
||||
"""
|
||||
Find semantic view by name within a semantic layer.
|
||||
|
||||
:param name: View name
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:return: SemanticView instance or None
|
||||
"""
|
||||
return (
|
||||
db.session.query(SemanticView)
|
||||
.filter(
|
||||
SemanticView.name == name,
|
||||
SemanticView.semantic_layer_uuid == layer_uuid,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
@@ -15,11 +15,14 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app as app, request
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
from flask_appbuilder.api import expose, protect, rison, safe
|
||||
from flask_appbuilder.api.schemas import get_list_schema
|
||||
|
||||
from superset import event_logger
|
||||
from superset import event_logger, is_feature_enabled, security_manager
|
||||
from superset.commands.datasource.list import GetCombinedDatasourceListCommand
|
||||
from superset.connectors.sqla.models import BaseDatasource
|
||||
from superset.daos.datasource import DatasourceDAO
|
||||
from superset.daos.exceptions import DatasourceNotFound, DatasourceTypeNotSupportedError
|
||||
@@ -303,3 +306,53 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
f"Invalid expression type: {expression_type}. "
|
||||
f"Valid types are: column, metric, where, having"
|
||||
) from None
|
||||
|
||||
@expose("/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@rison(get_list_schema)
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.combined_list",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def combined_list(self, **kwargs: Any) -> FlaskResponse:
|
||||
"""List datasets and semantic views combined.
|
||||
---
|
||||
get:
|
||||
summary: List datasets and semantic views combined
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/get_list_schema'
|
||||
responses:
|
||||
200:
|
||||
description: Combined list of datasets and semantic views
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
can_read_datasets = security_manager.can_access("can_read", "Dataset")
|
||||
can_read_sv = is_feature_enabled(
|
||||
"SEMANTIC_LAYERS"
|
||||
) and security_manager.can_access("can_read", "SemanticView")
|
||||
|
||||
if not can_read_datasets and not can_read_sv:
|
||||
return self.response(403, message="Access denied")
|
||||
|
||||
try:
|
||||
result = GetCombinedDatasourceListCommand(
|
||||
args=kwargs.get("rison", {}),
|
||||
can_read_datasets=can_read_datasets,
|
||||
can_read_semantic_views=can_read_sv,
|
||||
).run()
|
||||
except ValueError as ex:
|
||||
return self.response(400, message=str(ex))
|
||||
|
||||
return self.response(200, **result)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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.
|
||||
"""Marshmallow schemas for the combined datasource list endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from marshmallow import fields, Schema
|
||||
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.semantic_layers.models import SemanticView
|
||||
|
||||
|
||||
class _ChangedBySchema(Schema):
|
||||
first_name = fields.String()
|
||||
last_name = fields.String()
|
||||
|
||||
|
||||
class _OwnerSchema(Schema):
|
||||
id = fields.Integer()
|
||||
first_name = fields.String()
|
||||
last_name = fields.String()
|
||||
|
||||
|
||||
class _DatabaseSchema(Schema):
|
||||
id = fields.Integer()
|
||||
database_name = fields.String()
|
||||
|
||||
|
||||
class DatasetListSchema(Schema):
|
||||
"""Serializes a SqlaTable ORM object for the combined list response."""
|
||||
|
||||
id = fields.Integer()
|
||||
uuid = fields.Method("get_uuid")
|
||||
table_name = fields.String()
|
||||
kind = fields.String()
|
||||
source_type = fields.Constant("database")
|
||||
description = fields.String(allow_none=True)
|
||||
explore_url = fields.String()
|
||||
database = fields.Method("get_database")
|
||||
catalog = fields.String(allow_none=True)
|
||||
schema = fields.String(allow_none=True)
|
||||
sql = fields.String(allow_none=True)
|
||||
extra = fields.Raw(allow_none=True)
|
||||
default_endpoint = fields.String(allow_none=True)
|
||||
is_sqllab_view = fields.Boolean(allow_none=True)
|
||||
is_managed_externally = fields.Boolean(allow_none=True)
|
||||
owners = fields.Method("get_owners")
|
||||
changed_by_name = fields.String()
|
||||
changed_by = fields.Method("get_changed_by")
|
||||
changed_on_delta_humanized = fields.Method("get_changed_on_delta_humanized")
|
||||
changed_on_utc = fields.Method("get_changed_on_utc")
|
||||
|
||||
def get_uuid(self, obj: SqlaTable) -> str:
|
||||
return str(obj.uuid)
|
||||
|
||||
def get_database(self, obj: SqlaTable) -> dict[str, object] | None:
|
||||
if not obj.database:
|
||||
return None
|
||||
return _DatabaseSchema().dump(
|
||||
{"id": obj.database_id, "database_name": obj.database.database_name}
|
||||
)
|
||||
|
||||
def get_owners(self, obj: SqlaTable) -> list[dict[str, object]]:
|
||||
return _OwnerSchema(many=True).dump(
|
||||
[
|
||||
{"id": o.id, "first_name": o.first_name, "last_name": o.last_name}
|
||||
for o in obj.owners
|
||||
]
|
||||
)
|
||||
|
||||
def get_changed_by(self, obj: SqlaTable) -> dict[str, object] | None:
|
||||
if not obj.changed_by:
|
||||
return None
|
||||
return _ChangedBySchema().dump(
|
||||
{
|
||||
"first_name": obj.changed_by.first_name,
|
||||
"last_name": obj.changed_by.last_name,
|
||||
}
|
||||
)
|
||||
|
||||
def get_changed_on_delta_humanized(self, obj: SqlaTable) -> str:
|
||||
return obj.changed_on_delta_humanized()
|
||||
|
||||
def get_changed_on_utc(self, obj: SqlaTable) -> str:
|
||||
return obj.changed_on_utc()
|
||||
|
||||
|
||||
class SemanticViewListSchema(Schema):
|
||||
"""Serializes a SemanticView ORM object for the combined list response."""
|
||||
|
||||
id = fields.Integer()
|
||||
uuid = fields.Method("get_uuid")
|
||||
table_name = fields.Method("get_table_name")
|
||||
kind = fields.Constant("semantic_view")
|
||||
source_type = fields.Constant("semantic_layer")
|
||||
description = fields.String(allow_none=True)
|
||||
explore_url = fields.String()
|
||||
database = fields.Constant(None)
|
||||
catalog = fields.Constant(None)
|
||||
schema = fields.Constant(None)
|
||||
sql = fields.Constant(None)
|
||||
extra = fields.Constant(None)
|
||||
default_endpoint = fields.Constant(None)
|
||||
is_sqllab_view = fields.Constant(False)
|
||||
is_managed_externally = fields.Constant(False)
|
||||
owners = fields.Constant([])
|
||||
changed_by_name = fields.String()
|
||||
changed_by = fields.Method("get_changed_by")
|
||||
changed_on_delta_humanized = fields.Method("get_changed_on_delta_humanized")
|
||||
changed_on_utc = fields.Method("get_changed_on_utc")
|
||||
cache_timeout = fields.Integer(allow_none=True)
|
||||
|
||||
def get_uuid(self, obj: SemanticView) -> str:
|
||||
return str(obj.uuid)
|
||||
|
||||
def get_table_name(self, obj: SemanticView) -> str:
|
||||
return obj.name
|
||||
|
||||
def get_changed_by(self, obj: SemanticView) -> dict[str, object] | None:
|
||||
if not obj.changed_by:
|
||||
return None
|
||||
return _ChangedBySchema().dump(
|
||||
{
|
||||
"first_name": obj.changed_by.first_name,
|
||||
"last_name": obj.changed_by.last_name,
|
||||
}
|
||||
)
|
||||
|
||||
def get_changed_on_delta_humanized(self, obj: SemanticView) -> str:
|
||||
return obj.changed_on_delta_humanized()
|
||||
|
||||
def get_changed_on_utc(self, obj: SemanticView) -> str:
|
||||
return obj.changed_on_utc()
|
||||
@@ -53,6 +53,130 @@ class TimeGrainDict(TypedDict):
|
||||
duration: str | None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MetricMetadata(Protocol):
|
||||
"""
|
||||
Protocol for metric metadata objects.
|
||||
|
||||
Represents a metric that's available on an explorable data source.
|
||||
Metrics contain SQL expressions or references to semantic layer measures.
|
||||
|
||||
Attributes:
|
||||
metric_name: Unique identifier for the metric
|
||||
expression: SQL expression or reference for calculating the metric
|
||||
verbose_name: Human-readable name for display in the UI
|
||||
description: Description of what the metric represents
|
||||
d3format: D3 format string for formatting numeric values
|
||||
currency: Currency configuration for the metric (JSON object)
|
||||
warning_text: Warning message to display when using this metric
|
||||
certified_by: Person or entity that certified this metric
|
||||
certification_details: Details about the certification
|
||||
"""
|
||||
|
||||
@property
|
||||
def metric_name(self) -> str:
|
||||
"""Unique identifier for the metric."""
|
||||
|
||||
@property
|
||||
def expression(self) -> str:
|
||||
"""SQL expression or reference for calculating the metric."""
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str | None:
|
||||
"""Human-readable name for display in the UI."""
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Description of what the metric represents."""
|
||||
|
||||
@property
|
||||
def d3format(self) -> str | None:
|
||||
"""D3 format string for formatting numeric values."""
|
||||
|
||||
@property
|
||||
def currency(self) -> dict[str, Any] | None:
|
||||
"""Currency configuration for the metric (JSON object)."""
|
||||
|
||||
@property
|
||||
def warning_text(self) -> str | None:
|
||||
"""Warning message to display when using this metric."""
|
||||
|
||||
@property
|
||||
def certified_by(self) -> str | None:
|
||||
"""Person or entity that certified this metric."""
|
||||
|
||||
@property
|
||||
def certification_details(self) -> str | None:
|
||||
"""Details about the certification."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ColumnMetadata(Protocol):
|
||||
"""
|
||||
Protocol for column metadata objects.
|
||||
|
||||
Represents a column/dimension that's available on an explorable data source.
|
||||
Used for grouping, filtering, and dimension-based analysis.
|
||||
|
||||
Attributes:
|
||||
column_name: Unique identifier for the column
|
||||
type: SQL data type of the column (e.g., 'VARCHAR', 'INTEGER', 'DATETIME')
|
||||
is_dttm: Whether this column represents a date or time value
|
||||
verbose_name: Human-readable name for display in the UI
|
||||
description: Description of what the column represents
|
||||
groupby: Whether this column is allowed for grouping/aggregation
|
||||
filterable: Whether this column can be used in filters
|
||||
expression: SQL expression if this is a calculated column
|
||||
python_date_format: Python datetime format string for temporal columns
|
||||
advanced_data_type: Advanced data type classification
|
||||
extra: Additional metadata stored as JSON
|
||||
"""
|
||||
|
||||
@property
|
||||
def column_name(self) -> str:
|
||||
"""Unique identifier for the column."""
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""SQL data type of the column."""
|
||||
|
||||
@property
|
||||
def is_dttm(self) -> bool:
|
||||
"""Whether this column represents a date or time value."""
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str | None:
|
||||
"""Human-readable name for display in the UI."""
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Description of what the column represents."""
|
||||
|
||||
@property
|
||||
def groupby(self) -> bool:
|
||||
"""Whether this column is allowed for grouping/aggregation."""
|
||||
|
||||
@property
|
||||
def filterable(self) -> bool:
|
||||
"""Whether this column can be used in filters."""
|
||||
|
||||
@property
|
||||
def expression(self) -> str | None:
|
||||
"""SQL expression if this is a calculated column."""
|
||||
|
||||
@property
|
||||
def python_date_format(self) -> str | None:
|
||||
"""Python datetime format string for temporal columns."""
|
||||
|
||||
@property
|
||||
def advanced_data_type(self) -> str | None:
|
||||
"""Advanced data type classification."""
|
||||
|
||||
@property
|
||||
def extra(self) -> str | None:
|
||||
"""Additional metadata stored as JSON."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Explorable(Protocol):
|
||||
"""
|
||||
@@ -144,7 +268,7 @@ class Explorable(Protocol):
|
||||
"""
|
||||
|
||||
@property
|
||||
def metrics(self) -> list[Any]:
|
||||
def metrics(self) -> list[MetricMetadata]:
|
||||
"""
|
||||
List of metric metadata objects.
|
||||
|
||||
@@ -159,7 +283,7 @@ class Explorable(Protocol):
|
||||
|
||||
# TODO: rename to dimensions
|
||||
@property
|
||||
def columns(self) -> list[Any]:
|
||||
def columns(self) -> list[ColumnMetadata]:
|
||||
"""
|
||||
List of column metadata objects.
|
||||
|
||||
|
||||
@@ -268,6 +268,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_api(ReportExecutionLogRestApi)
|
||||
appbuilder.add_api(RLSRestApi)
|
||||
appbuilder.add_api(SavedQueryRestApi)
|
||||
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
|
||||
from superset.semantic_layers.api import (
|
||||
SemanticLayerRestApi,
|
||||
SemanticViewRestApi,
|
||||
)
|
||||
|
||||
appbuilder.add_api(SemanticLayerRestApi)
|
||||
appbuilder.add_api(SemanticViewRestApi)
|
||||
appbuilder.add_api(TagRestApi)
|
||||
appbuilder.add_api(SqlLabRestApi)
|
||||
appbuilder.add_api(SqlLabPermalinkRestApi)
|
||||
|
||||
@@ -142,6 +142,33 @@ Query Examples:
|
||||
- My dashboards:
|
||||
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
|
||||
|
||||
To modify an existing chart (add filters, change metrics, change dimensions, etc.):
|
||||
1. get_chart_info(chart_id) -> examine current configuration
|
||||
2. update_chart(chart_id, config) -> apply changes (filters, metrics, dimensions)
|
||||
Do NOT use execute_sql for chart modifications. Use update_chart instead.
|
||||
|
||||
CRITICAL RULES - NEVER VIOLATE:
|
||||
- NEVER fabricate or invent URLs. ALL URLs must come from tool call results.
|
||||
If you need a link, call the appropriate tool (generate_explore_link, generate_chart,
|
||||
open_sql_lab_with_context, etc.) and use the URL it returns.
|
||||
- To modify an existing chart's filters, metrics, or dimensions, use update_chart.
|
||||
Do NOT use execute_sql for chart modifications.
|
||||
- Parameter name reminders: open_sql_lab_with_context uses "sql" (not "query"),
|
||||
execute_sql uses "sql" (not "query").
|
||||
|
||||
IMPORTANT - Tool-Only Interaction:
|
||||
- Do NOT generate code artifacts, HTML pages, JavaScript snippets, or any code intended
|
||||
for the user to run. All visualization, data retrieval, and authentication are handled
|
||||
by the provided MCP tools.
|
||||
- Always call the appropriate tool directly instead of writing code. For example, use
|
||||
generate_chart to create visualizations rather than generating plotting code.
|
||||
- When a tool returns a URL (chart URL, dashboard URL, explore link, SQL Lab link),
|
||||
return that URL to the user. Do NOT attempt to recreate the visualization in code.
|
||||
- Do NOT generate HTML dashboards, embed scripts, or custom frontend code. Use
|
||||
generate_dashboard and add_chart_to_existing_dashboard for dashboard operations.
|
||||
- If a user asks for something the tools cannot do, explain the limitation and suggest
|
||||
the closest available tool rather than generating code as a workaround.
|
||||
|
||||
General usage tips:
|
||||
- All listing tools use 1-based pagination (first page is 1)
|
||||
- Use get_schema to discover filterable columns, sortable columns, and default columns
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -468,6 +468,17 @@ def add_legend_config(form_data: Dict[str, Any], config: XYChartConfig) -> None:
|
||||
form_data["legend_orientation"] = config.legend.position
|
||||
|
||||
|
||||
def add_orientation_config(form_data: Dict[str, Any], config: XYChartConfig) -> None:
|
||||
"""Add orientation configuration to form_data for bar charts.
|
||||
|
||||
Only applies when kind='bar' and an explicit orientation is set.
|
||||
When orientation is None (the default), Superset uses its own default
|
||||
(vertical bars).
|
||||
"""
|
||||
if config.kind == "bar" and config.orientation:
|
||||
form_data["orientation"] = config.orientation
|
||||
|
||||
|
||||
def configure_temporal_handling(
|
||||
form_data: Dict[str, Any],
|
||||
x_is_temporal: bool,
|
||||
@@ -576,6 +587,7 @@ def map_xy_config(
|
||||
# Add configurations
|
||||
add_axis_config(form_data, config)
|
||||
add_legend_config(form_data, config)
|
||||
add_orientation_config(form_data, config)
|
||||
|
||||
return form_data
|
||||
|
||||
@@ -789,34 +801,165 @@ def map_filter_operator(op: str) -> str:
|
||||
return operator_map.get(op, op)
|
||||
|
||||
|
||||
def _humanize_column(col: ColumnRef) -> str:
|
||||
"""Return a human-readable label for a column reference."""
|
||||
if col.label:
|
||||
return col.label
|
||||
name = col.name.replace("_", " ").title()
|
||||
if col.aggregate:
|
||||
return f"{col.aggregate.capitalize()}({name})"
|
||||
return name
|
||||
|
||||
|
||||
def _summarize_filters(
|
||||
filters: list[Any] | None,
|
||||
) -> str | None:
|
||||
"""Extract a short context string from filter configs."""
|
||||
if not filters:
|
||||
return None
|
||||
parts: list[str] = []
|
||||
for f in filters[:2]:
|
||||
col = getattr(f, "column", "")
|
||||
val = getattr(f, "value", "")
|
||||
if isinstance(val, list):
|
||||
val = ", ".join(str(v) for v in val[:3])
|
||||
parts.append(f"{str(col).replace('_', ' ').title()} {val}")
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _truncate(name: str, max_length: int = 60) -> str:
|
||||
"""Truncate to *max_length*, preserving the en-dash context portion."""
|
||||
if len(name) <= max_length:
|
||||
return name
|
||||
if " \u2013 " in name:
|
||||
what, _context = name.split(" \u2013 ", 1)
|
||||
if len(what) <= max_length:
|
||||
return what
|
||||
return name[: max_length - 1] + "\u2026"
|
||||
|
||||
|
||||
def _table_chart_what(config: TableChartConfig, dataset_name: str | None) -> str:
|
||||
"""Build the descriptive fragment for a table chart."""
|
||||
has_agg = any(col.aggregate for col in config.columns)
|
||||
if has_agg:
|
||||
metrics = [col for col in config.columns if col.aggregate]
|
||||
what = ", ".join(_humanize_column(m) for m in metrics[:2])
|
||||
return f"{what} Summary"
|
||||
if dataset_name:
|
||||
return f"{dataset_name} Records"
|
||||
cols = ", ".join(_humanize_column(c) for c in config.columns[:3])
|
||||
return f"{cols} Table"
|
||||
|
||||
|
||||
def _xy_chart_what(config: XYChartConfig) -> str:
|
||||
"""Build the descriptive fragment for an XY chart."""
|
||||
primary_metric = _humanize_column(config.y[0]) if config.y else "Value"
|
||||
dimension = _humanize_column(config.x)
|
||||
|
||||
if config.kind in ("line", "area") and config.group_by is None:
|
||||
return f"{primary_metric} Over Time"
|
||||
if config.group_by is not None:
|
||||
group_label = _humanize_column(config.group_by)
|
||||
return f"{primary_metric} by {group_label}"
|
||||
if config.kind == "scatter":
|
||||
return f"{primary_metric} vs {dimension}"
|
||||
return f"{primary_metric} by {dimension}"
|
||||
|
||||
|
||||
_GRAIN_MAP: dict[str, str] = {
|
||||
"PT1H": "Hourly",
|
||||
"P1D": "Daily",
|
||||
"P1W": "Weekly",
|
||||
"P1M": "Monthly",
|
||||
"P3M": "Quarterly",
|
||||
"P1Y": "Yearly",
|
||||
}
|
||||
|
||||
|
||||
def _xy_chart_context(config: XYChartConfig) -> str | None:
|
||||
"""Build context (time grain / filters) for an XY chart name."""
|
||||
parts: list[str] = []
|
||||
if config.time_grain:
|
||||
grain_val = (
|
||||
config.time_grain.value
|
||||
if hasattr(config.time_grain, "value")
|
||||
else str(config.time_grain)
|
||||
)
|
||||
grain_str = _GRAIN_MAP.get(grain_val, grain_val)
|
||||
parts.append(grain_str)
|
||||
if filter_ctx := _summarize_filters(config.filters):
|
||||
parts.append(filter_ctx)
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _pie_chart_what(config: PieChartConfig) -> str:
|
||||
"""Build the 'what' portion for a pie chart name."""
|
||||
dim = config.dimension.name
|
||||
metric_label = config.metric.label or config.metric.name
|
||||
return f"{dim} by {metric_label}"
|
||||
|
||||
|
||||
def _pivot_table_what(config: PivotTableChartConfig) -> str:
|
||||
"""Build the 'what' portion for a pivot table chart name."""
|
||||
row_names = ", ".join(r.name for r in config.rows)
|
||||
return f"Pivot Table \u2013 {row_names}"
|
||||
|
||||
|
||||
def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str:
|
||||
"""Build the 'what' portion for a mixed timeseries chart name."""
|
||||
primary = config.y[0].label or config.y[0].name if config.y else "primary"
|
||||
secondary = (
|
||||
config.y_secondary[0].label or config.y_secondary[0].name
|
||||
if config.y_secondary
|
||||
else "secondary"
|
||||
)
|
||||
return f"{primary} + {secondary}"
|
||||
|
||||
|
||||
def generate_chart_name(
|
||||
config: TableChartConfig
|
||||
| XYChartConfig
|
||||
| PieChartConfig
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig,
|
||||
dataset_name: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a chart name based on the configuration."""
|
||||
"""Generate a descriptive chart name following a standard format.
|
||||
|
||||
Format conventions (by chart type):
|
||||
Aggregated (bar/scatter with group_by): [Metric] by [Dimension]
|
||||
Time-series (line/area, no group_by): [Metric] Over Time
|
||||
Table (no aggregates): [Dataset] Records
|
||||
Table (with aggregates): [Metric] Summary
|
||||
Pie: [Dimension] by [Metric]
|
||||
Pivot Table: Pivot Table – [Row1, Row2]
|
||||
Mixed Timeseries: [Primary] + [Secondary]
|
||||
An en-dash followed by context (filters / time grain) is appended
|
||||
when such information is available.
|
||||
"""
|
||||
if isinstance(config, TableChartConfig):
|
||||
return f"Table Chart - {', '.join(col.name for col in config.columns)}"
|
||||
what = _table_chart_what(config, dataset_name)
|
||||
context = _summarize_filters(config.filters)
|
||||
elif isinstance(config, XYChartConfig):
|
||||
chart_type = config.kind.capitalize()
|
||||
x_col = config.x.name
|
||||
y_cols = ", ".join(col.name for col in config.y)
|
||||
return f"{chart_type} Chart - {x_col} vs {y_cols}"
|
||||
what = _xy_chart_what(config)
|
||||
context = _xy_chart_context(config)
|
||||
elif isinstance(config, PieChartConfig):
|
||||
metric_label = config.metric.label or config.metric.name
|
||||
return f"Pie Chart - {config.dimension.name} by {metric_label}"
|
||||
what = _pie_chart_what(config)
|
||||
context = _summarize_filters(config.filters)
|
||||
elif isinstance(config, PivotTableChartConfig):
|
||||
rows = ", ".join(col.name for col in config.rows)
|
||||
return f"Pivot Table - {rows}"
|
||||
what = _pivot_table_what(config)
|
||||
context = _summarize_filters(config.filters)
|
||||
elif isinstance(config, MixedTimeseriesChartConfig):
|
||||
primary = ", ".join(col.name for col in config.y)
|
||||
secondary = ", ".join(col.name for col in config.y_secondary)
|
||||
return f"Mixed Chart - {primary} + {secondary}"
|
||||
what = _mixed_timeseries_what(config)
|
||||
context = _summarize_filters(config.filters)
|
||||
else:
|
||||
return "Chart"
|
||||
|
||||
name = what
|
||||
if context:
|
||||
name = f"{what} \u2013 {context}"
|
||||
return _truncate(name)
|
||||
|
||||
|
||||
def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilities:
|
||||
"""Analyze chart capabilities based on type and configuration."""
|
||||
|
||||
@@ -103,6 +103,7 @@ def generate_preview_from_form_data(
|
||||
|
||||
# Execute query
|
||||
command = ChartDataCommand(query_context_obj)
|
||||
command.validate()
|
||||
result = command.run()
|
||||
|
||||
if not result or not result.get("queries"):
|
||||
|
||||
@@ -129,6 +129,29 @@ def get_chart_configs_resource() -> str:
|
||||
},
|
||||
"use_cases": ["Correlation analysis", "Outlier detection"],
|
||||
},
|
||||
"horizontal_bar": {
|
||||
"description": "Horizontal bar chart for categories with long names",
|
||||
"config": {
|
||||
"chart_type": "xy",
|
||||
"kind": "bar",
|
||||
"orientation": "horizontal",
|
||||
"x": {"name": "department", "label": "Department"},
|
||||
"y": [
|
||||
{
|
||||
"name": "headcount",
|
||||
"aggregate": "SUM",
|
||||
"label": "Headcount",
|
||||
}
|
||||
],
|
||||
"y_axis": {"title": "Department"},
|
||||
"x_axis": {"title": "Number of Employees"},
|
||||
},
|
||||
"use_cases": [
|
||||
"Long category labels",
|
||||
"Rankings and leaderboards",
|
||||
"Survey results",
|
||||
],
|
||||
},
|
||||
"stacked_area": {
|
||||
"description": "Stacked area chart for volume composition over time",
|
||||
"config": {
|
||||
@@ -215,6 +238,7 @@ def get_chart_configs_resource() -> str:
|
||||
"Use group_by to split data into series for comparison",
|
||||
"Use stacked=true for bar/area charts showing composition",
|
||||
"Configure axis format for readability ($,.0f for currency, .2% for pct)",
|
||||
"Use orientation='horizontal' for bar charts with long category names",
|
||||
],
|
||||
"table_charts": [
|
||||
"Include only essential columns to avoid clutter",
|
||||
|
||||
@@ -749,6 +749,14 @@ class XYChartConfig(BaseModel):
|
||||
"If not specified, Superset will use its default behavior."
|
||||
),
|
||||
)
|
||||
orientation: Literal["vertical", "horizontal"] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Bar chart orientation. Only applies when kind='bar'. "
|
||||
"'vertical' (default): bars extend upward. "
|
||||
"'horizontal': bars extend rightward, useful for long category names."
|
||||
),
|
||||
)
|
||||
stacked: bool = Field(
|
||||
False,
|
||||
description="Stack bars/areas on top of each other instead of side-by-side",
|
||||
|
||||
@@ -101,6 +101,7 @@ def _compile_chart(
|
||||
)
|
||||
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
result = command.run()
|
||||
|
||||
warnings: List[str] = []
|
||||
@@ -263,10 +264,6 @@ async def generate_chart( # noqa: C901
|
||||
await ctx.report_progress(2, 5, "Creating chart in database")
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
|
||||
# Use custom chart name if provided, otherwise auto-generate
|
||||
chart_name = request.chart_name or generate_chart_name(request.config)
|
||||
await ctx.debug("Chart name: chart_name=%s" % (chart_name,))
|
||||
|
||||
# Find the dataset to get its numeric ID
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
|
||||
@@ -343,6 +340,15 @@ async def generate_chart( # noqa: C901
|
||||
}
|
||||
)
|
||||
|
||||
# Generate chart name after dataset lookup so we can include dataset name
|
||||
dataset_name = getattr(dataset, "datasource_name", None) or getattr(
|
||||
dataset, "table_name", None
|
||||
)
|
||||
chart_name = request.chart_name or generate_chart_name(
|
||||
request.config, dataset_name=dataset_name
|
||||
)
|
||||
await ctx.debug("Chart name: chart_name=%s" % (chart_name,))
|
||||
|
||||
try:
|
||||
with event_logger.log_context(action="mcp.generate_chart.db_write"):
|
||||
command = CreateChartCommand(
|
||||
|
||||
@@ -462,6 +462,7 @@ async def get_chart_data( # noqa: C901
|
||||
# Execute the query
|
||||
with event_logger.log_context(action="mcp.get_chart_data.query_execution"):
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
result = command.run()
|
||||
|
||||
# Handle empty query results for certain chart types
|
||||
|
||||
@@ -160,6 +160,7 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
|
||||
)
|
||||
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
result = command.run()
|
||||
|
||||
data = []
|
||||
@@ -234,6 +235,7 @@ class TablePreviewStrategy(PreviewFormatStrategy):
|
||||
)
|
||||
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
result = command.run()
|
||||
|
||||
data = []
|
||||
@@ -340,6 +342,7 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
|
||||
|
||||
# Execute the query
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
result = command.run()
|
||||
|
||||
# Extract data from result
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -22,6 +22,7 @@ from typing import Any, Awaitable, Callable, Dict, Protocol
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
||||
from flask import has_app_context
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import OperationalError, TimeoutError
|
||||
from starlette.exceptions import HTTPException
|
||||
@@ -171,24 +172,25 @@ class LoggingMiddleware(Middleware):
|
||||
return result
|
||||
finally:
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_tool_call",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=duration_ms,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": tool_name,
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
"success": success,
|
||||
},
|
||||
)
|
||||
if has_app_context():
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_tool_call",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=duration_ms,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": tool_name,
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
"success": success,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
|
||||
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
|
||||
@@ -213,23 +215,24 @@ class LoggingMiddleware(Middleware):
|
||||
agent_id, user_id, dashboard_id, slice_id, dataset_id, params = (
|
||||
self._extract_context_info(context)
|
||||
)
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_message",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=None,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": getattr(context.message, "name", None),
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
)
|
||||
if has_app_context():
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_message",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=None,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": getattr(context.message, "name", None),
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
|
||||
getattr(context.message, "name", None),
|
||||
|
||||
@@ -30,7 +30,11 @@ import uvicorn
|
||||
|
||||
from superset.mcp_service.app import create_mcp_app, init_fastmcp_server
|
||||
from superset.mcp_service.mcp_config import get_mcp_factory_config, MCP_STORE_CONFIG
|
||||
from superset.mcp_service.middleware import create_response_size_guard_middleware
|
||||
from superset.mcp_service.middleware import (
|
||||
create_response_size_guard_middleware,
|
||||
GlobalErrorHandlerMiddleware,
|
||||
LoggingMiddleware,
|
||||
)
|
||||
from superset.mcp_service.storage import _create_redis_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -224,16 +228,24 @@ def run_server(
|
||||
auth_provider = _create_auth_provider(flask_app)
|
||||
|
||||
# Build middleware list
|
||||
# FastMCP wraps handlers so that the LAST-added middleware is
|
||||
# outermost. Order here is innermost → outermost.
|
||||
middleware_list = []
|
||||
|
||||
# Add caching middleware (innermost – runs closest to the tool)
|
||||
caching_middleware = create_response_caching_middleware()
|
||||
if caching_middleware:
|
||||
middleware_list.append(caching_middleware)
|
||||
|
||||
# Add response size guard (protects LLM clients from huge responses)
|
||||
if size_guard_middleware := create_response_size_guard_middleware():
|
||||
middleware_list.append(size_guard_middleware)
|
||||
|
||||
# Add caching middleware
|
||||
caching_middleware = create_response_caching_middleware()
|
||||
if caching_middleware:
|
||||
middleware_list.append(caching_middleware)
|
||||
# Add logging middleware (logs all tool calls with duration tracking)
|
||||
middleware_list.append(LoggingMiddleware())
|
||||
|
||||
# Add global error handler (outermost – catches all exceptions)
|
||||
middleware_list.append(GlobalErrorHandlerMiddleware())
|
||||
|
||||
mcp_instance = init_fastmcp_server(
|
||||
auth=auth_provider,
|
||||
|
||||
@@ -33,6 +33,7 @@ from superset.mcp_service.sql_lab.schemas import (
|
||||
SqlLabResponse,
|
||||
)
|
||||
from superset.mcp_service.utils.schema_utils import parse_request
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -95,7 +96,7 @@ def open_sql_lab_with_context(
|
||||
|
||||
# Construct SQL Lab URL
|
||||
query_string = urlencode(params)
|
||||
url = f"/sqllab?{query_string}"
|
||||
url = f"{get_superset_base_url()}/sqllab?{query_string}"
|
||||
|
||||
logger.info(
|
||||
"Generated SQL Lab URL for database %s", request.database_connection_id
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
"""add_semantic_layers_and_views
|
||||
|
||||
Revision ID: 33d7e0e21daa
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2025-11-04 11:26:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy_utils import UUIDType
|
||||
from sqlalchemy_utils.types.json import JSONType
|
||||
|
||||
from superset.extensions import encrypted_field_factory
|
||||
from superset.migrations.shared.utils import (
|
||||
create_fks_for_table,
|
||||
create_table,
|
||||
drop_table,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "33d7e0e21daa"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create semantic_layers table
|
||||
create_table(
|
||||
"semantic_layers",
|
||||
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, nullable=False),
|
||||
# created_on and changed_on are nullable=True to match AuditMixinNullable
|
||||
sa.Column("created_on", sa.DateTime(), nullable=False),
|
||||
sa.Column("changed_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("name", sa.String(length=250), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("type", sa.String(length=250), nullable=False),
|
||||
sa.Column(
|
||||
"configuration",
|
||||
encrypted_field_factory.create(JSONType),
|
||||
nullable=True,
|
||||
),
|
||||
# configuration_version tracks the schema version of the configuration
|
||||
# JSON field to aid with migrations as the schema evolves over time.
|
||||
sa.Column(
|
||||
"configuration_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
),
|
||||
sa.Column("cache_timeout", sa.Integer(), nullable=True),
|
||||
sa.Column("created_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("uuid"),
|
||||
)
|
||||
|
||||
# Create foreign key constraints for semantic_layers
|
||||
create_fks_for_table(
|
||||
"fk_semantic_layers_created_by_fk_ab_user",
|
||||
"semantic_layers",
|
||||
"ab_user",
|
||||
["created_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
create_fks_for_table(
|
||||
"fk_semantic_layers_changed_by_fk_ab_user",
|
||||
"semantic_layers",
|
||||
"ab_user",
|
||||
["changed_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Create semantic_views table.
|
||||
# The integer `id` is the primary key (auto-increment across all supported
|
||||
# databases) and `uuid` is a secondary unique identifier. This follows the
|
||||
# standard Superset model pattern and avoids using sa.Identity(), which is
|
||||
# not supported in MySQL or SQLite.
|
||||
create_table(
|
||||
"semantic_views",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, nullable=False),
|
||||
# created_on and changed_on are nullable=True to match AuditMixinNullable
|
||||
sa.Column("created_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("changed_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("name", sa.String(length=250), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"configuration",
|
||||
encrypted_field_factory.create(JSONType),
|
||||
nullable=True,
|
||||
),
|
||||
# configuration_version tracks the schema version of the configuration
|
||||
# JSON field to aid with migrations as the schema evolves over time.
|
||||
sa.Column(
|
||||
"configuration_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
),
|
||||
sa.Column("cache_timeout", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"semantic_layer_uuid",
|
||||
UUIDType(binary=True),
|
||||
sa.ForeignKey("semantic_layers.uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("uuid"),
|
||||
)
|
||||
|
||||
# Create foreign key constraints for semantic_views
|
||||
create_fks_for_table(
|
||||
"fk_semantic_views_created_by_fk_ab_user",
|
||||
"semantic_views",
|
||||
"ab_user",
|
||||
["created_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
create_fks_for_table(
|
||||
"fk_semantic_views_changed_by_fk_ab_user",
|
||||
"semantic_views",
|
||||
"ab_user",
|
||||
["changed_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Update chart datasource constraint to allow semantic_view
|
||||
with op.batch_alter_table("slices") as batch_op:
|
||||
batch_op.drop_constraint("ck_chart_datasource", type_="check")
|
||||
batch_op.create_check_constraint(
|
||||
"ck_chart_datasource",
|
||||
"datasource_type in ('table', 'semantic_view')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Restore original constraint
|
||||
with op.batch_alter_table("slices") as batch_op:
|
||||
batch_op.drop_constraint("ck_chart_datasource", type_="check")
|
||||
batch_op.create_check_constraint(
|
||||
"ck_chart_datasource", "datasource_type in ('table')"
|
||||
)
|
||||
|
||||
drop_table("semantic_views")
|
||||
drop_table("semantic_layers")
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# 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.
|
||||
"""add granular export permissions
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 4b2a8c9d3e1f
|
||||
Create Date: 2026-03-02 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a1b2c3d4e5f6"
|
||||
down_revision = "4b2a8c9d3e1f"
|
||||
|
||||
from alembic import op # noqa: E402
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from superset.migrations.shared.security_converge import ( # noqa: E402
|
||||
add_pvms,
|
||||
get_reversed_new_pvms,
|
||||
get_reversed_pvm_map,
|
||||
migrate_roles,
|
||||
Pvm,
|
||||
)
|
||||
|
||||
NEW_PVMS = {
|
||||
"Superset": (
|
||||
"can_export_data",
|
||||
"can_export_image",
|
||||
"can_copy_clipboard",
|
||||
)
|
||||
}
|
||||
|
||||
PVM_MAP = {
|
||||
Pvm("Superset", "can_csv"): (
|
||||
Pvm("Superset", "can_export_data"),
|
||||
Pvm("Superset", "can_export_image"),
|
||||
Pvm("Superset", "can_copy_clipboard"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def do_upgrade(session: Session) -> None:
|
||||
add_pvms(session, NEW_PVMS)
|
||||
migrate_roles(session, PVM_MAP)
|
||||
|
||||
|
||||
def do_downgrade(session: Session) -> None:
|
||||
add_pvms(session, get_reversed_new_pvms(PVM_MAP))
|
||||
migrate_roles(session, get_reversed_pvm_map(PVM_MAP))
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
do_upgrade(session)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
do_downgrade(session)
|
||||
@@ -22,7 +22,7 @@ import logging
|
||||
import re
|
||||
from collections.abc import Hashable
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
from typing import Any, cast, Optional, TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sqla
|
||||
from flask import current_app as app
|
||||
@@ -67,7 +67,7 @@ from superset.sql.parse import (
|
||||
Table,
|
||||
)
|
||||
from superset.sqllab.limiting_factor import LimitingFactor
|
||||
from superset.superset_typing import ExplorableData, QueryObjectDict
|
||||
from superset.superset_typing import DatasetColumnData, ExplorableData, QueryObjectDict
|
||||
from superset.utils import json
|
||||
from superset.utils.core import (
|
||||
get_column_name,
|
||||
@@ -261,7 +261,7 @@ class Query(
|
||||
],
|
||||
"filter_select": True,
|
||||
"name": self.tab_name,
|
||||
"columns": [o.data for o in self.columns],
|
||||
"columns": [cast(DatasetColumnData, o.data) for o in self.columns],
|
||||
"metrics": [],
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
|
||||
@@ -280,6 +280,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
"Datasource",
|
||||
} | READ_ONLY_MODEL_VIEWS
|
||||
|
||||
GAMMA_EXCLUDED_PVMS = {
|
||||
("can_export_data", "Superset"),
|
||||
("can_export_image", "Superset"),
|
||||
}
|
||||
|
||||
ADMIN_ONLY_VIEW_MENUS = {
|
||||
"Access Requests",
|
||||
"Action Logs",
|
||||
@@ -396,6 +401,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
|
||||
SQLLAB_EXTRA_PERMISSION_VIEWS = {
|
||||
("can_csv", "Superset"), # Deprecated permission remove on 3.0.0
|
||||
("can_export_data", "Superset"),
|
||||
("can_copy_clipboard", "Superset"),
|
||||
("can_read", "Superset"),
|
||||
("can_read", "Database"),
|
||||
}
|
||||
@@ -424,6 +431,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
("can_read", "Theme"),
|
||||
# Embedded dashboard support
|
||||
("can_read", "EmbeddedDashboard"),
|
||||
("can_read", "CurrentUserRestApi"),
|
||||
# Datasource metadata for chart rendering
|
||||
("can_get", "Datasource"),
|
||||
("can_external_metadata", "Datasource"),
|
||||
@@ -1195,6 +1203,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
self.add_permission_view_menu("all_database_access", "all_database_access")
|
||||
self.add_permission_view_menu("all_query_access", "all_query_access")
|
||||
self.add_permission_view_menu("can_csv", "Superset")
|
||||
self.add_permission_view_menu("can_export_data", "Superset")
|
||||
self.add_permission_view_menu("can_export_image", "Superset")
|
||||
self.add_permission_view_menu("can_copy_clipboard", "Superset")
|
||||
self.add_permission_view_menu("can_share_dashboard", "Superset")
|
||||
self.add_permission_view_menu("can_share_chart", "Superset")
|
||||
self.add_permission_view_menu("can_sqllab", "Superset")
|
||||
@@ -1476,6 +1487,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
or self._is_admin_only(pvm)
|
||||
or self._is_alpha_only(pvm)
|
||||
or self._is_sql_lab_only(pvm)
|
||||
or (pvm.permission.name, pvm.view_menu.name) in self.GAMMA_EXCLUDED_PVMS
|
||||
) or self._is_accessible_to_all(pvm)
|
||||
|
||||
def _is_sql_lab_only(self, pvm: PermissionView) -> bool:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,780 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import make_response, request, Response
|
||||
from flask_appbuilder.api import expose, protect, rison, safe
|
||||
from flask_appbuilder.api.schemas import get_list_schema
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from marshmallow import ValidationError
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from sqlalchemy.orm import load_only
|
||||
|
||||
from superset import db, event_logger, is_feature_enabled
|
||||
from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.commands.semantic_layer.update import (
|
||||
UpdateSemanticLayerCommand,
|
||||
UpdateSemanticViewCommand,
|
||||
)
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO
|
||||
from superset.models.core import Database
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.semantic_layers.schemas import (
|
||||
SemanticLayerPostSchema,
|
||||
SemanticLayerPutSchema,
|
||||
SemanticViewPutSchema,
|
||||
)
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import json
|
||||
from superset.views.base_api import (
|
||||
BaseSupersetApi,
|
||||
BaseSupersetModelRestApi,
|
||||
requires_json,
|
||||
statsd_metrics,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_layer(layer: SemanticLayer) -> dict[str, Any]:
|
||||
config = layer.configuration
|
||||
if isinstance(config, str):
|
||||
config = json.loads(config)
|
||||
return {
|
||||
"uuid": str(layer.uuid),
|
||||
"name": layer.name,
|
||||
"description": layer.description,
|
||||
"type": layer.type,
|
||||
"cache_timeout": layer.cache_timeout,
|
||||
"configuration": config or {},
|
||||
"changed_on_delta_humanized": layer.changed_on_delta_humanized(),
|
||||
}
|
||||
|
||||
|
||||
def _infer_discriminators(
|
||||
schema: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Infer discriminator values for union fields when the frontend omits them.
|
||||
|
||||
Walks the schema's properties looking for discriminated unions (fields with a
|
||||
``discriminator.mapping``). For each one, tries to match the submitted data
|
||||
against one of the variants by checking which variant's required fields are
|
||||
present, then injects the discriminator value.
|
||||
"""
|
||||
defs = schema.get("$defs", {})
|
||||
for prop_name, prop_schema in schema.get("properties", {}).items():
|
||||
value = data.get(prop_name)
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
|
||||
# Find discriminated union via discriminator mapping
|
||||
mapping = (
|
||||
prop_schema.get("discriminator", {}).get("mapping")
|
||||
if "discriminator" in prop_schema
|
||||
else None
|
||||
)
|
||||
if not mapping:
|
||||
continue
|
||||
|
||||
discriminator_field = prop_schema["discriminator"].get("propertyName")
|
||||
if not discriminator_field or discriminator_field in value:
|
||||
continue
|
||||
|
||||
# Try each variant: match by required fields present in the data
|
||||
for disc_value, ref in mapping.items():
|
||||
ref_name = ref.rsplit("/", 1)[-1] if "/" in ref else ref
|
||||
variant_def = defs.get(ref_name, {})
|
||||
required = set(variant_def.get("required", []))
|
||||
# Exclude the discriminator itself from the check
|
||||
required.discard(discriminator_field)
|
||||
if required and required.issubset(value.keys()):
|
||||
data = {
|
||||
**data,
|
||||
prop_name: {**value, discriminator_field: disc_value},
|
||||
}
|
||||
break
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _parse_partial_config(
|
||||
cls: Any,
|
||||
config: dict[str, Any],
|
||||
) -> Any:
|
||||
"""
|
||||
Parse a partial configuration, handling discriminator inference and
|
||||
falling back to lenient validation when strict parsing fails.
|
||||
"""
|
||||
config_class = cls.configuration_class
|
||||
|
||||
# Infer discriminator values the frontend may have omitted
|
||||
schema = config_class.model_json_schema()
|
||||
config = _infer_discriminators(schema, config)
|
||||
|
||||
try:
|
||||
return config_class.model_validate(config)
|
||||
except (PydanticValidationError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
return config_class.model_validate(config, context={"partial": True})
|
||||
except (PydanticValidationError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
datamodel = SQLAInterface(SemanticView)
|
||||
|
||||
resource_name = "semantic_view"
|
||||
allow_browser_login = True
|
||||
class_permission_name = "SemanticView"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
include_route_methods = {"put"}
|
||||
|
||||
edit_model_schema = SemanticViewPutSchema()
|
||||
|
||||
@expose("/<pk>", methods=("PUT",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@requires_json
|
||||
def put(self, pk: int) -> Response:
|
||||
"""Update a semantic view.
|
||||
---
|
||||
put:
|
||||
summary: Update a semantic view
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
requestBody:
|
||||
description: Semantic view schema
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
|
||||
responses:
|
||||
200:
|
||||
description: Semantic view changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
result:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
try:
|
||||
item = self.edit_model_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
try:
|
||||
changed_model = UpdateSemanticViewCommand(pk, item).run()
|
||||
response = self.response(200, id=changed_model.id, result=item)
|
||||
except SemanticViewNotFoundError:
|
||||
response = self.response_404()
|
||||
except SemanticViewForbiddenError:
|
||||
response = self.response_403()
|
||||
except SemanticViewInvalidError as ex:
|
||||
response = self.response_422(message=ex.normalized_messages())
|
||||
except SemanticViewUpdateFailedError as ex:
|
||||
logger.error(
|
||||
"Error updating model %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
response = self.response_422(message=str(ex))
|
||||
return response
|
||||
|
||||
|
||||
class SemanticLayerRestApi(BaseSupersetApi):
|
||||
resource_name = "semantic_layer"
|
||||
allow_browser_login = True
|
||||
class_permission_name = "SemanticLayer"
|
||||
method_permission_name = {
|
||||
**MODEL_API_RW_METHOD_PERMISSION_MAP,
|
||||
"types": "read",
|
||||
"configuration_schema": "read",
|
||||
"runtime_schema": "read",
|
||||
}
|
||||
openapi_spec_tag = "Semantic Layers"
|
||||
add_model_schema = SemanticLayerPostSchema()
|
||||
edit_model_schema = SemanticLayerPutSchema()
|
||||
|
||||
@expose("/types", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
def types(self) -> FlaskResponse:
|
||||
"""List available semantic layer types.
|
||||
---
|
||||
get:
|
||||
summary: List available semantic layer types
|
||||
responses:
|
||||
200:
|
||||
description: A list of semantic layer types
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
"""
|
||||
result = [
|
||||
{"id": key, "name": cls.name, "description": cls.description} # type: ignore[attr-defined]
|
||||
for key, cls in registry.items()
|
||||
]
|
||||
return self.response(200, result=result)
|
||||
|
||||
@expose("/schema/configuration", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@requires_json
|
||||
def configuration_schema(self) -> FlaskResponse:
|
||||
"""Get configuration schema for a semantic layer type.
|
||||
---
|
||||
post:
|
||||
summary: Get configuration schema for a semantic layer type
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
configuration:
|
||||
type: object
|
||||
responses:
|
||||
200:
|
||||
description: Configuration JSON Schema
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
"""
|
||||
body = request.json or {}
|
||||
sl_type = body.get("type")
|
||||
|
||||
cls = registry.get(sl_type) # type: ignore[arg-type]
|
||||
if not cls:
|
||||
return self.response_400(message=f"Unknown type: {sl_type}")
|
||||
|
||||
parsed_config = None
|
||||
if config := body.get("configuration"):
|
||||
parsed_config = _parse_partial_config(cls, config)
|
||||
|
||||
warning: str | None = None
|
||||
try:
|
||||
schema = cls.get_configuration_schema(parsed_config)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
warning = str(ex)
|
||||
logger.exception(
|
||||
"Error enriching semantic layer configuration schema for type %s",
|
||||
sl_type,
|
||||
)
|
||||
# Connection or query failures during schema enrichment should not
|
||||
# prevent the form from rendering — return the base schema instead.
|
||||
schema = cls.get_configuration_schema(None)
|
||||
|
||||
payload: dict[str, Any] = {"result": schema}
|
||||
if warning:
|
||||
payload["warning"] = warning
|
||||
resp = make_response(json.dumps(payload, sort_keys=False), 200)
|
||||
resp.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
return resp
|
||||
|
||||
@expose("/<uuid>/schema/runtime", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
def runtime_schema(self, uuid: str) -> FlaskResponse:
|
||||
"""Get runtime schema for a stored semantic layer.
|
||||
---
|
||||
post:
|
||||
summary: Get runtime schema for a semantic layer
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
name: uuid
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
runtime_data:
|
||||
type: object
|
||||
responses:
|
||||
200:
|
||||
description: Runtime JSON Schema
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
layer = SemanticLayerDAO.find_by_uuid(uuid)
|
||||
if not layer:
|
||||
return self.response_404()
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
runtime_data = body.get("runtime_data")
|
||||
|
||||
cls = registry.get(layer.type)
|
||||
if not cls:
|
||||
return self.response_400(message=f"Unknown type: {layer.type}")
|
||||
|
||||
try:
|
||||
schema = cls.get_runtime_schema(
|
||||
layer.implementation.configuration, # type: ignore[attr-defined]
|
||||
runtime_data,
|
||||
)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
return self.response_400(message=str(ex))
|
||||
|
||||
return self.response(200, result=schema)
|
||||
|
||||
@expose("/", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@requires_json
|
||||
def post(self) -> FlaskResponse:
|
||||
"""Create a semantic layer.
|
||||
---
|
||||
post:
|
||||
summary: Create a semantic layer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
configuration:
|
||||
type: object
|
||||
cache_timeout:
|
||||
type: integer
|
||||
responses:
|
||||
201:
|
||||
description: Semantic layer created
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
try:
|
||||
item = self.add_model_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
try:
|
||||
new_model = CreateSemanticLayerCommand(item).run()
|
||||
return self.response(201, result={"uuid": str(new_model.uuid)})
|
||||
except SemanticLayerInvalidError as ex:
|
||||
return self.response_422(message=str(ex))
|
||||
except SemanticLayerCreateFailedError as ex:
|
||||
logger.error(
|
||||
"Error creating semantic layer: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/<uuid>", methods=("PUT",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@requires_json
|
||||
def put(self, uuid: str) -> FlaskResponse:
|
||||
"""Update a semantic layer.
|
||||
---
|
||||
put:
|
||||
summary: Update a semantic layer
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
name: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
configuration:
|
||||
type: object
|
||||
cache_timeout:
|
||||
type: integer
|
||||
responses:
|
||||
200:
|
||||
description: Semantic layer updated
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
try:
|
||||
item = self.edit_model_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
try:
|
||||
changed_model = UpdateSemanticLayerCommand(uuid, item).run()
|
||||
return self.response(200, result={"uuid": str(changed_model.uuid)})
|
||||
except SemanticLayerNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticLayerInvalidError as ex:
|
||||
return self.response_422(message=str(ex))
|
||||
except SemanticLayerUpdateFailedError as ex:
|
||||
logger.error(
|
||||
"Error updating semantic layer: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/<uuid>", methods=("DELETE",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
def delete(self, uuid: str) -> FlaskResponse:
|
||||
"""Delete a semantic layer.
|
||||
---
|
||||
delete:
|
||||
summary: Delete a semantic layer
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
name: uuid
|
||||
responses:
|
||||
200:
|
||||
description: Semantic layer deleted
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
try:
|
||||
DeleteSemanticLayerCommand(uuid).run()
|
||||
return self.response(200, message="OK")
|
||||
except SemanticLayerNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticLayerDeleteFailedError as ex:
|
||||
logger.error(
|
||||
"Error deleting semantic layer: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/connections/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@rison(get_list_schema)
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.connections",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def connections(self, **kwargs: Any) -> FlaskResponse:
|
||||
"""List databases and semantic layers combined.
|
||||
---
|
||||
get:
|
||||
summary: List databases and semantic layers combined
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/get_list_schema'
|
||||
responses:
|
||||
200:
|
||||
description: Combined list of databases and semantic layers
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
args = kwargs.get("rison", {})
|
||||
page = args.get("page", 0)
|
||||
page_size = args.get("page_size", 25)
|
||||
order_column = args.get("order_column", "changed_on")
|
||||
order_direction = args.get("order_direction", "desc")
|
||||
filters = args.get("filters", [])
|
||||
|
||||
source_type, name_filter = self._parse_connection_filters(filters)
|
||||
|
||||
if not is_feature_enabled("SEMANTIC_LAYERS"):
|
||||
return self.response_404()
|
||||
|
||||
all_items = self._fetch_connection_items(source_type, name_filter)
|
||||
|
||||
sort_key = self._get_connection_sort_key(order_column)
|
||||
all_items.sort(key=sort_key, reverse=order_direction == "desc") # type: ignore
|
||||
total_count = len(all_items)
|
||||
|
||||
start = page * page_size
|
||||
page_items = all_items[start : start + page_size]
|
||||
|
||||
result = [
|
||||
self._serialize_database(obj)
|
||||
if item_type == "database"
|
||||
else self._serialize_semantic_layer(obj)
|
||||
for item_type, obj in page_items
|
||||
]
|
||||
|
||||
return self.response(200, count=total_count, result=result)
|
||||
|
||||
@staticmethod
|
||||
def _parse_connection_filters(
|
||||
filters: list[dict[str, Any]],
|
||||
) -> tuple[str, str | None]:
|
||||
"""Parse filters into source_type and name_filter."""
|
||||
source_type = "all"
|
||||
name_filter = None
|
||||
for f in filters:
|
||||
if f.get("col") == "source_type":
|
||||
source_type = f.get("value", "all")
|
||||
elif f.get("col") == "database_name" and f.get("opr") == "ct":
|
||||
name_filter = f.get("value")
|
||||
return source_type, name_filter
|
||||
|
||||
@staticmethod
|
||||
def _fetch_connection_items(
|
||||
source_type: str,
|
||||
name_filter: str | None,
|
||||
) -> list[tuple[str, Any]]:
|
||||
"""Fetch database and semantic layer items based on filters."""
|
||||
db_items: list[tuple[str, Database]] = []
|
||||
if source_type in ("all", "database"):
|
||||
db_q = db.session.query(Database).options(
|
||||
load_only(
|
||||
Database.id,
|
||||
Database.uuid,
|
||||
Database.database_name,
|
||||
Database.backend,
|
||||
Database.allow_run_async,
|
||||
Database.allow_dml,
|
||||
Database.allow_file_upload,
|
||||
Database.expose_in_sqllab,
|
||||
Database.changed_on,
|
||||
Database.changed_by_fk,
|
||||
)
|
||||
)
|
||||
if name_filter:
|
||||
db_q = db_q.filter(Database.database_name.ilike(f"%{name_filter}%"))
|
||||
db_items = [("database", obj) for obj in db_q.all()]
|
||||
|
||||
sl_items: list[tuple[str, SemanticLayer]] = []
|
||||
if source_type in ("all", "semantic_layer"):
|
||||
sl_q = db.session.query(SemanticLayer).options(
|
||||
load_only(
|
||||
SemanticLayer.uuid,
|
||||
SemanticLayer.name,
|
||||
SemanticLayer.type,
|
||||
SemanticLayer.description,
|
||||
SemanticLayer.changed_on,
|
||||
SemanticLayer.changed_by_fk,
|
||||
)
|
||||
)
|
||||
if name_filter:
|
||||
sl_q = sl_q.filter(SemanticLayer.name.ilike(f"%{name_filter}%"))
|
||||
sl_items = [("semantic_layer", obj) for obj in sl_q.all()]
|
||||
|
||||
# TODO: move sort + pagination to SQL before GA.
|
||||
return db_items + sl_items # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def _get_connection_sort_key(order_column: str) -> Any:
|
||||
"""Return a sort key function for connection items."""
|
||||
|
||||
def _sort_key_changed_on(
|
||||
item: tuple[str, Database | SemanticLayer],
|
||||
) -> float:
|
||||
changed_on = item[1].changed_on
|
||||
return changed_on.timestamp() if changed_on else 0.0
|
||||
|
||||
def _sort_key_name(
|
||||
item: tuple[str, Database | SemanticLayer],
|
||||
) -> str:
|
||||
obj = item[1]
|
||||
raw = (
|
||||
obj.database_name # type: ignore[union-attr]
|
||||
if item[0] == "database"
|
||||
else obj.name
|
||||
)
|
||||
return raw.lower()
|
||||
|
||||
sort_key_map = {
|
||||
"changed_on_delta_humanized": _sort_key_changed_on,
|
||||
"database_name": _sort_key_name,
|
||||
}
|
||||
return sort_key_map.get(order_column, _sort_key_changed_on)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_database(obj: Database) -> dict[str, Any]:
|
||||
changed_by = obj.changed_by
|
||||
return {
|
||||
"source_type": "database",
|
||||
"id": obj.id,
|
||||
"uuid": str(obj.uuid),
|
||||
"database_name": obj.database_name,
|
||||
"backend": obj.backend,
|
||||
"allow_run_async": obj.allow_run_async,
|
||||
"allow_dml": obj.allow_dml,
|
||||
"allow_file_upload": obj.allow_file_upload,
|
||||
"expose_in_sqllab": obj.expose_in_sqllab,
|
||||
"changed_on_delta_humanized": obj.changed_on_delta_humanized(),
|
||||
"changed_by": {
|
||||
"first_name": changed_by.first_name,
|
||||
"last_name": changed_by.last_name,
|
||||
}
|
||||
if changed_by
|
||||
else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_semantic_layer(obj: SemanticLayer) -> dict[str, Any]:
|
||||
changed_by = obj.changed_by
|
||||
sl_type = obj.type
|
||||
cls = registry.get(sl_type)
|
||||
type_name = cls.name if cls else sl_type # type: ignore[attr-defined]
|
||||
return {
|
||||
"source_type": "semantic_layer",
|
||||
"uuid": str(obj.uuid),
|
||||
"database_name": obj.name,
|
||||
"backend": type_name,
|
||||
"sl_type": sl_type,
|
||||
"description": obj.description,
|
||||
"allow_run_async": None,
|
||||
"allow_dml": None,
|
||||
"allow_file_upload": None,
|
||||
"expose_in_sqllab": None,
|
||||
"changed_on_delta_humanized": obj.changed_on_delta_humanized(),
|
||||
"changed_by": {
|
||||
"first_name": changed_by.first_name,
|
||||
"last_name": changed_by.last_name,
|
||||
}
|
||||
if changed_by
|
||||
else None,
|
||||
}
|
||||
|
||||
@expose("/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
def get_list(self) -> FlaskResponse:
|
||||
"""List all semantic layers.
|
||||
---
|
||||
get:
|
||||
summary: List all semantic layers
|
||||
responses:
|
||||
200:
|
||||
description: A list of semantic layers
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
"""
|
||||
layers = SemanticLayerDAO.find_all()
|
||||
result = [_serialize_layer(layer) for layer in layers]
|
||||
return self.response(200, result=result)
|
||||
|
||||
@expose("/<uuid>", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
def get(self, uuid: str) -> FlaskResponse:
|
||||
"""Get a single semantic layer.
|
||||
---
|
||||
get:
|
||||
summary: Get a semantic layer by UUID
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
name: uuid
|
||||
responses:
|
||||
200:
|
||||
description: A semantic layer
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
layer = SemanticLayerDAO.find_by_uuid(uuid)
|
||||
if not layer:
|
||||
return self.response_404()
|
||||
return self.response(200, result=_serialize_layer(layer))
|
||||
@@ -0,0 +1,912 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Functions for mapping `QueryObject` to semantic layers.
|
||||
|
||||
These functions validate and convert a `QueryObject` into one or more `SemanticQuery`,
|
||||
which are then passed to semantic layer implementations for execution, returning a
|
||||
single dataframe.
|
||||
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from time import time
|
||||
from typing import Any, cast, Sequence, TypeGuard
|
||||
|
||||
import isodate
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
from superset_core.semantic_layers.types import (
|
||||
AdhocExpression,
|
||||
Dimension,
|
||||
Filter,
|
||||
FilterValues,
|
||||
Grain,
|
||||
Grains,
|
||||
GroupLimit,
|
||||
Metric,
|
||||
Operator,
|
||||
OrderDirection,
|
||||
OrderTuple,
|
||||
PredicateType,
|
||||
SemanticQuery,
|
||||
SemanticResult,
|
||||
)
|
||||
from superset_core.semantic_layers.view import SemanticViewFeature
|
||||
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.common.utils.time_range_utils import get_since_until_from_query_object
|
||||
from superset.connectors.sqla.models import BaseDatasource
|
||||
from superset.constants import NO_TIME_RANGE
|
||||
from superset.models.helpers import QueryResult
|
||||
from superset.superset_typing import AdhocColumn
|
||||
from superset.utils.core import (
|
||||
FilterOperator,
|
||||
QueryObjectFilterClause,
|
||||
TIME_COMPARISON,
|
||||
)
|
||||
from superset.utils.date_parser import get_past_or_future
|
||||
|
||||
|
||||
class ValidatedQueryObjectFilterClause(QueryObjectFilterClause):
|
||||
"""
|
||||
A validated QueryObject filter clause with a string column name.
|
||||
|
||||
The `col` in a `QueryObjectFilterClause` can be either a string (column name) or an
|
||||
adhoc column, but we only support the former in semantic layers.
|
||||
"""
|
||||
|
||||
# overwrite to narrow type; mypy complains about more restrictive typed dicts,
|
||||
# but the alternative would be to redefine the object
|
||||
col: str # type: ignore[misc]
|
||||
op: str # type: ignore[misc]
|
||||
|
||||
|
||||
class ValidatedQueryObject(QueryObject):
|
||||
"""
|
||||
A query object that has a datasource defined.
|
||||
"""
|
||||
|
||||
datasource: BaseDatasource
|
||||
|
||||
# overwrite to narrow type; mypy complains about the assignment since the base type
|
||||
# allows adhoc filters, but we only support validated filters here
|
||||
filter: list[ValidatedQueryObjectFilterClause] # type: ignore[assignment]
|
||||
series_columns: Sequence[str] # type: ignore[assignment]
|
||||
series_limit_metric: str | None
|
||||
|
||||
|
||||
def get_results(query_object: QueryObject) -> QueryResult:
|
||||
"""
|
||||
Run 1+ queries based on `QueryObject` and return the results.
|
||||
|
||||
:param query_object: The QueryObject containing query specifications
|
||||
:return: QueryResult compatible with Superset's query interface
|
||||
"""
|
||||
if not validate_query_object(query_object):
|
||||
raise ValueError("QueryObject must have a datasource defined.")
|
||||
|
||||
# Track execution time
|
||||
start_time = time()
|
||||
|
||||
semantic_view = query_object.datasource.implementation
|
||||
dispatcher = (
|
||||
semantic_view.get_row_count
|
||||
if query_object.is_rowcount
|
||||
else semantic_view.get_table
|
||||
)
|
||||
|
||||
# Step 1: Convert QueryObject to list of SemanticQuery objects
|
||||
# The first query is the main query, subsequent queries are for time offsets
|
||||
queries = map_query_object(query_object)
|
||||
|
||||
# Step 2: Execute the main query (first in the list)
|
||||
main_query = queries[0]
|
||||
main_result = dispatcher(main_query)
|
||||
|
||||
main_df = main_result.results.to_pandas()
|
||||
|
||||
# Collect all requests (SQL queries, HTTP requests, etc.) for troubleshooting
|
||||
all_requests = list(main_result.requests)
|
||||
|
||||
# If no time offsets, return the main result as-is
|
||||
if not query_object.time_offsets or len(queries) <= 1:
|
||||
duration = timedelta(seconds=time() - start_time)
|
||||
return map_semantic_result_to_query_result(
|
||||
main_result,
|
||||
query_object,
|
||||
duration,
|
||||
)
|
||||
|
||||
# Get metric names from the main query
|
||||
# These are the columns that will be renamed with offset suffixes
|
||||
metric_names = [metric.name for metric in main_query.metrics]
|
||||
|
||||
# Join keys are all columns except metrics
|
||||
# These will be used to match rows between main and offset DataFrames
|
||||
join_keys = [col for col in main_df.columns if col not in metric_names]
|
||||
|
||||
# Step 3 & 4: Execute each time offset query and join results
|
||||
for offset_query, time_offset in zip(
|
||||
queries[1:],
|
||||
query_object.time_offsets,
|
||||
strict=False,
|
||||
):
|
||||
# Execute the offset query
|
||||
result = dispatcher(offset_query)
|
||||
|
||||
# Add this query's requests to the collection
|
||||
all_requests.extend(result.requests)
|
||||
|
||||
offset_df = result.results.to_pandas()
|
||||
|
||||
# Handle empty results - add NaN columns directly instead of merging
|
||||
# This avoids dtype mismatch issues with empty DataFrames
|
||||
if offset_df.empty:
|
||||
# Add offset metric columns with NaN values directly to main_df
|
||||
for metric in metric_names:
|
||||
offset_col_name = TIME_COMPARISON.join([metric, time_offset])
|
||||
main_df[offset_col_name] = np.nan
|
||||
else:
|
||||
# Rename metric columns with time offset suffix
|
||||
# Format: "{metric_name}__{time_offset}"
|
||||
# Example: "revenue" -> "revenue__1 week ago"
|
||||
offset_df = offset_df.rename(
|
||||
columns={
|
||||
metric: TIME_COMPARISON.join([metric, time_offset])
|
||||
for metric in metric_names
|
||||
}
|
||||
)
|
||||
|
||||
# Step 5: Perform left join on dimension columns
|
||||
# This preserves all rows from main_df and adds offset metrics
|
||||
# where they match
|
||||
main_df = main_df.merge(
|
||||
offset_df,
|
||||
on=join_keys,
|
||||
how="left",
|
||||
suffixes=("", "__duplicate"),
|
||||
)
|
||||
|
||||
# Clean up any duplicate columns that might have been created
|
||||
# (shouldn't happen with proper join keys, but defensive programming)
|
||||
duplicate_cols = [
|
||||
col for col in main_df.columns if col.endswith("__duplicate")
|
||||
]
|
||||
if duplicate_cols:
|
||||
main_df = main_df.drop(columns=duplicate_cols)
|
||||
|
||||
# Convert final result to QueryResult
|
||||
semantic_result = SemanticResult(
|
||||
requests=all_requests,
|
||||
results=pa.Table.from_pandas(main_df),
|
||||
)
|
||||
duration = timedelta(seconds=time() - start_time)
|
||||
return map_semantic_result_to_query_result(
|
||||
semantic_result,
|
||||
query_object,
|
||||
duration,
|
||||
)
|
||||
|
||||
|
||||
def map_semantic_result_to_query_result(
|
||||
semantic_result: SemanticResult,
|
||||
query_object: ValidatedQueryObject,
|
||||
duration: timedelta,
|
||||
) -> QueryResult:
|
||||
"""
|
||||
Convert a SemanticResult to a QueryResult.
|
||||
|
||||
:param semantic_result: Result from the semantic layer
|
||||
:param query_object: Original QueryObject (for passthrough attributes)
|
||||
:param duration: Time taken to execute the query
|
||||
:return: QueryResult compatible with Superset's query interface
|
||||
"""
|
||||
# Get the query string from requests (typically one or more SQL queries)
|
||||
query_str = ""
|
||||
if semantic_result.requests:
|
||||
# Join all requests for display (could be multiple for time comparisons)
|
||||
query_str = "\n\n".join(
|
||||
f"-- {req.type}\n{req.definition}" for req in semantic_result.requests
|
||||
)
|
||||
|
||||
return QueryResult(
|
||||
# Core data
|
||||
df=semantic_result.results.to_pandas(),
|
||||
query=query_str,
|
||||
duration=duration,
|
||||
# Template filters - not applicable to semantic layers
|
||||
# (semantic layers don't use Jinja templates)
|
||||
applied_template_filters=None,
|
||||
# Filter columns - not applicable to semantic layers
|
||||
# (semantic layers handle filter validation internally)
|
||||
applied_filter_columns=None,
|
||||
rejected_filter_columns=None,
|
||||
# Status - always success if we got here
|
||||
# (errors would raise exceptions before reaching this point)
|
||||
status=QueryStatus.SUCCESS,
|
||||
error_message=None,
|
||||
errors=None,
|
||||
# Time range - pass through from original query_object
|
||||
from_dttm=query_object.from_dttm,
|
||||
to_dttm=query_object.to_dttm,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_column(column: str | AdhocColumn, dimension_names: set[str]) -> str:
|
||||
"""
|
||||
Normalize a column to its dimension name.
|
||||
|
||||
Columns can be either:
|
||||
- A string (dimension name directly)
|
||||
- An AdhocColumn with isColumnReference=True and sqlExpression containing the
|
||||
dimension name
|
||||
"""
|
||||
if isinstance(column, str):
|
||||
return column
|
||||
|
||||
# Handle column references (e.g., from time-series charts)
|
||||
if column.get("isColumnReference") and (sql_expr := column.get("sqlExpression")):
|
||||
if sql_expr in dimension_names:
|
||||
return sql_expr
|
||||
|
||||
raise ValueError("Adhoc dimensions are not supported in Semantic Views.")
|
||||
|
||||
|
||||
def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]:
|
||||
"""
|
||||
Convert a `QueryObject` into a list of `SemanticQuery`.
|
||||
|
||||
This function maps the `QueryObject` into query objects that focus less on
|
||||
visualization and more on semantics.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
all_metrics = {metric.name: metric for metric in semantic_view.metrics}
|
||||
all_dimensions = {
|
||||
dimension.name: dimension for dimension in semantic_view.dimensions
|
||||
}
|
||||
|
||||
# Normalize columns (may be dicts with isColumnReference=True for time-series)
|
||||
dimension_names = set(all_dimensions.keys())
|
||||
normalized_columns = {
|
||||
_normalize_column(column, dimension_names) for column in query_object.columns
|
||||
}
|
||||
|
||||
metrics = [all_metrics[metric] for metric in (query_object.metrics or [])]
|
||||
|
||||
grain = (
|
||||
_convert_time_grain(query_object.extras["time_grain_sqla"])
|
||||
if "time_grain_sqla" in query_object.extras
|
||||
else None
|
||||
)
|
||||
dimensions = [
|
||||
dimension
|
||||
for dimension in semantic_view.dimensions
|
||||
if dimension.name in normalized_columns
|
||||
and (
|
||||
# if a grain is specified, only include the time dimension if its grain
|
||||
# matches the requested grain
|
||||
grain is None
|
||||
or dimension.name != query_object.granularity
|
||||
or dimension.grain == grain
|
||||
)
|
||||
]
|
||||
|
||||
order = _get_order_from_query_object(query_object, all_metrics, all_dimensions)
|
||||
limit = query_object.row_limit
|
||||
offset = query_object.row_offset
|
||||
|
||||
group_limit = _get_group_limit_from_query_object(
|
||||
query_object,
|
||||
all_metrics,
|
||||
all_dimensions,
|
||||
)
|
||||
|
||||
queries = []
|
||||
for time_offset in [None] + query_object.time_offsets:
|
||||
filters = _get_filters_from_query_object(
|
||||
query_object,
|
||||
time_offset,
|
||||
all_dimensions,
|
||||
)
|
||||
queries.append(
|
||||
SemanticQuery(
|
||||
metrics=metrics,
|
||||
dimensions=dimensions,
|
||||
filters=filters,
|
||||
order=order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
group_limit=group_limit,
|
||||
)
|
||||
)
|
||||
|
||||
return queries
|
||||
|
||||
|
||||
def _get_filters_from_query_object(
|
||||
query_object: ValidatedQueryObject,
|
||||
time_offset: str | None,
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> set[Filter]:
|
||||
"""
|
||||
Extract all filters from the query object, including time range filters.
|
||||
|
||||
This simplifies the complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm
|
||||
by converting all time constraints into filters.
|
||||
"""
|
||||
filters: set[Filter] = set()
|
||||
|
||||
# 1. Add fetch values predicate if present
|
||||
if (
|
||||
query_object.apply_fetch_values_predicate
|
||||
and query_object.datasource.fetch_values_predicate
|
||||
):
|
||||
filters.add(
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=None,
|
||||
operator=Operator.ADHOC,
|
||||
value=query_object.datasource.fetch_values_predicate,
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Add time range filter based on from_dttm/to_dttm
|
||||
# For time offsets, this automatically calculates the shifted bounds
|
||||
time_filters = _get_time_filter(query_object, time_offset, all_dimensions)
|
||||
filters.update(time_filters)
|
||||
|
||||
# 3. Add filters from query_object.extras (WHERE and HAVING clauses)
|
||||
extras_filters = _get_filters_from_extras(query_object.extras)
|
||||
filters.update(extras_filters)
|
||||
|
||||
# 4. Add all other filters from query_object.filter
|
||||
for filter_ in query_object.filter:
|
||||
# Skip temporal range filters - we're using inner bounds instead
|
||||
if (
|
||||
filter_.get("op") == FilterOperator.TEMPORAL_RANGE.value
|
||||
and query_object.granularity
|
||||
):
|
||||
continue
|
||||
|
||||
if converted_filters := _convert_query_object_filter(filter_, all_dimensions):
|
||||
filters.update(converted_filters)
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def _get_filters_from_extras(extras: dict[str, Any]) -> set[Filter]:
|
||||
"""
|
||||
Extract filters from the extras dict.
|
||||
|
||||
The extras dict can contain various keys that affect query behavior:
|
||||
|
||||
Supported keys (converted to filters):
|
||||
- "where": SQL WHERE clause expression (e.g., "customer_id > 100")
|
||||
- "having": SQL HAVING clause expression (e.g., "SUM(sales) > 1000")
|
||||
|
||||
Other keys in extras (handled elsewhere in the mapper):
|
||||
- "time_grain_sqla": Time granularity (e.g., "P1D", "PT1H")
|
||||
Handled in _convert_time_grain() and used for dimension grain matching
|
||||
|
||||
Note: The WHERE and HAVING clauses from extras are SQL expressions that
|
||||
are passed through as-is to the semantic layer as adhoc Filter objects.
|
||||
"""
|
||||
filters: set[Filter] = set()
|
||||
|
||||
# Add WHERE clause from extras
|
||||
if where_clause := extras.get("where"):
|
||||
filters.add(
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=None,
|
||||
operator=Operator.ADHOC,
|
||||
value=where_clause,
|
||||
)
|
||||
)
|
||||
|
||||
# Add HAVING clause from extras
|
||||
if having_clause := extras.get("having"):
|
||||
filters.add(
|
||||
Filter(
|
||||
type=PredicateType.HAVING,
|
||||
column=None,
|
||||
operator=Operator.ADHOC,
|
||||
value=having_clause,
|
||||
)
|
||||
)
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def _get_time_filter(
|
||||
query_object: ValidatedQueryObject,
|
||||
time_offset: str | None,
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> set[Filter]:
|
||||
"""
|
||||
Create a time range filter from the query object.
|
||||
|
||||
This handles both regular queries and time offset queries, simplifying the
|
||||
complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm by using the
|
||||
same time bounds for both the main query and series limit subqueries.
|
||||
"""
|
||||
filters: set[Filter] = set()
|
||||
|
||||
if not query_object.granularity:
|
||||
return filters
|
||||
|
||||
time_dimension = all_dimensions.get(query_object.granularity)
|
||||
if not time_dimension:
|
||||
return filters
|
||||
|
||||
# Get the appropriate time bounds based on whether this is a time offset query
|
||||
from_dttm, to_dttm = _get_time_bounds(query_object, time_offset)
|
||||
|
||||
if not from_dttm or not to_dttm:
|
||||
return filters
|
||||
|
||||
# Create a filter with >= and < operators
|
||||
return {
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=time_dimension,
|
||||
operator=Operator.GREATER_THAN_OR_EQUAL,
|
||||
value=from_dttm,
|
||||
),
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=time_dimension,
|
||||
operator=Operator.LESS_THAN,
|
||||
value=to_dttm,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _get_time_bounds(
|
||||
query_object: ValidatedQueryObject,
|
||||
time_offset: str | None,
|
||||
) -> tuple[datetime | None, datetime | None]:
|
||||
"""
|
||||
Get the appropriate time bounds for the query.
|
||||
|
||||
For regular queries (time_offset is None), returns from_dttm/to_dttm.
|
||||
For time offset queries, calculates the shifted bounds.
|
||||
|
||||
This simplifies the inner_from_dttm/inner_to_dttm complexity by using
|
||||
the same bounds for both main queries and series limit subqueries (Option 1).
|
||||
"""
|
||||
if time_offset is None:
|
||||
# Main query: use from_dttm/to_dttm directly
|
||||
return query_object.from_dttm, query_object.to_dttm
|
||||
|
||||
# Time offset query: calculate shifted bounds
|
||||
# Use from_dttm/to_dttm if available, otherwise try to get from time_range
|
||||
outer_from = query_object.from_dttm
|
||||
outer_to = query_object.to_dttm
|
||||
|
||||
if not outer_from or not outer_to:
|
||||
# Fall back to parsing time_range if from_dttm/to_dttm not set
|
||||
outer_from, outer_to = get_since_until_from_query_object(query_object)
|
||||
|
||||
if not outer_from or not outer_to:
|
||||
return None, None
|
||||
|
||||
# Apply the offset to both bounds
|
||||
offset_from = get_past_or_future(time_offset, outer_from)
|
||||
offset_to = get_past_or_future(time_offset, outer_to)
|
||||
|
||||
return offset_from, offset_to
|
||||
|
||||
|
||||
def _convert_query_object_filter(
|
||||
filter_: ValidatedQueryObjectFilterClause,
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> set[Filter] | None:
|
||||
"""
|
||||
Convert a QueryObject filter dict to a semantic layer Filter.
|
||||
"""
|
||||
operator_str = filter_["op"]
|
||||
|
||||
# Handle simple column filters
|
||||
col = filter_.get("col")
|
||||
if col not in all_dimensions:
|
||||
return None
|
||||
|
||||
dimension = all_dimensions[col]
|
||||
|
||||
val_str = filter_["val"]
|
||||
value: FilterValues | frozenset[FilterValues]
|
||||
if val_str is None:
|
||||
value = None
|
||||
elif isinstance(val_str, (list, tuple)):
|
||||
value = frozenset(val_str)
|
||||
else:
|
||||
value = val_str
|
||||
|
||||
# Special case for temporal range
|
||||
if operator_str == FilterOperator.TEMPORAL_RANGE.value:
|
||||
if not isinstance(value, str) or value == NO_TIME_RANGE:
|
||||
return None
|
||||
start, end = value.split(" : ")
|
||||
return {
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=dimension,
|
||||
operator=Operator.GREATER_THAN_OR_EQUAL,
|
||||
value=start,
|
||||
),
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=dimension,
|
||||
operator=Operator.LESS_THAN,
|
||||
value=end,
|
||||
),
|
||||
}
|
||||
|
||||
# Map QueryObject operators to semantic layer operators
|
||||
operator_mapping = {
|
||||
FilterOperator.EQUALS.value: Operator.EQUALS,
|
||||
FilterOperator.NOT_EQUALS.value: Operator.NOT_EQUALS,
|
||||
FilterOperator.GREATER_THAN.value: Operator.GREATER_THAN,
|
||||
FilterOperator.LESS_THAN.value: Operator.LESS_THAN,
|
||||
FilterOperator.GREATER_THAN_OR_EQUALS.value: Operator.GREATER_THAN_OR_EQUAL,
|
||||
FilterOperator.LESS_THAN_OR_EQUALS.value: Operator.LESS_THAN_OR_EQUAL,
|
||||
FilterOperator.IN.value: Operator.IN,
|
||||
FilterOperator.NOT_IN.value: Operator.NOT_IN,
|
||||
FilterOperator.LIKE.value: Operator.LIKE,
|
||||
FilterOperator.NOT_LIKE.value: Operator.NOT_LIKE,
|
||||
FilterOperator.IS_NULL.value: Operator.IS_NULL,
|
||||
FilterOperator.IS_NOT_NULL.value: Operator.IS_NOT_NULL,
|
||||
}
|
||||
|
||||
operator = operator_mapping.get(operator_str)
|
||||
if not operator:
|
||||
# Unknown operator - raise error to prevent unauthorized access
|
||||
raise ValueError(f"Unsupported filter operator: {operator_str}")
|
||||
|
||||
return {
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=dimension,
|
||||
operator=operator,
|
||||
value=value,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _get_order_from_query_object(
|
||||
query_object: ValidatedQueryObject,
|
||||
all_metrics: dict[str, Metric],
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> list[OrderTuple]:
|
||||
order: list[OrderTuple] = []
|
||||
for element, ascending in query_object.orderby:
|
||||
direction = OrderDirection.ASC if ascending else OrderDirection.DESC
|
||||
|
||||
# adhoc
|
||||
if isinstance(element, dict):
|
||||
if element["sqlExpression"] is not None:
|
||||
order.append(
|
||||
(
|
||||
AdhocExpression(
|
||||
id=element["label"] or element["sqlExpression"],
|
||||
definition=element["sqlExpression"],
|
||||
),
|
||||
direction,
|
||||
)
|
||||
)
|
||||
elif element in all_dimensions:
|
||||
order.append((all_dimensions[element], direction))
|
||||
elif element in all_metrics:
|
||||
order.append((all_metrics[element], direction))
|
||||
|
||||
return order
|
||||
|
||||
|
||||
def _get_group_limit_from_query_object(
|
||||
query_object: ValidatedQueryObject,
|
||||
all_metrics: dict[str, Metric],
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> GroupLimit | None:
|
||||
# no limit
|
||||
if query_object.series_limit == 0 or not query_object.columns:
|
||||
return None
|
||||
|
||||
dimensions = [all_dimensions[dim_id] for dim_id in query_object.series_columns]
|
||||
top = query_object.series_limit
|
||||
metric = (
|
||||
all_metrics[query_object.series_limit_metric]
|
||||
if query_object.series_limit_metric
|
||||
else None
|
||||
)
|
||||
direction = OrderDirection.DESC if query_object.order_desc else OrderDirection.ASC
|
||||
group_others = query_object.group_others_when_limit_reached
|
||||
|
||||
# Check if we need separate filters for the group limit subquery
|
||||
# This happens when inner_from_dttm/inner_to_dttm differ from from_dttm/to_dttm
|
||||
group_limit_filters = _get_group_limit_filters(query_object, all_dimensions)
|
||||
|
||||
return GroupLimit(
|
||||
dimensions=dimensions,
|
||||
top=top,
|
||||
metric=metric,
|
||||
direction=direction,
|
||||
group_others=group_others,
|
||||
filters=group_limit_filters,
|
||||
)
|
||||
|
||||
|
||||
def _get_group_limit_filters(
|
||||
query_object: ValidatedQueryObject,
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> set[Filter] | None:
|
||||
"""
|
||||
Get separate filters for the group limit subquery if needed.
|
||||
|
||||
This is used when inner_from_dttm/inner_to_dttm differ from from_dttm/to_dttm,
|
||||
which happens during time comparison queries. The group limit subquery may need
|
||||
different time bounds to determine the top N groups.
|
||||
|
||||
Returns None if the group limit should use the same filters as the main query.
|
||||
"""
|
||||
# Check if inner time bounds are explicitly set and differ from outer bounds
|
||||
if (
|
||||
query_object.inner_from_dttm is None
|
||||
or query_object.inner_to_dttm is None
|
||||
or (
|
||||
query_object.inner_from_dttm == query_object.from_dttm
|
||||
and query_object.inner_to_dttm == query_object.to_dttm
|
||||
)
|
||||
):
|
||||
# No separate bounds needed - use the same filters as the main query
|
||||
return None
|
||||
|
||||
# Create separate filters for the group limit subquery
|
||||
filters: set[Filter] = set()
|
||||
|
||||
# Add time range filter using inner bounds
|
||||
if query_object.granularity:
|
||||
time_dimension = all_dimensions.get(query_object.granularity)
|
||||
if (
|
||||
time_dimension
|
||||
and query_object.inner_from_dttm
|
||||
and query_object.inner_to_dttm
|
||||
):
|
||||
filters.update(
|
||||
{
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=time_dimension,
|
||||
operator=Operator.GREATER_THAN_OR_EQUAL,
|
||||
value=query_object.inner_from_dttm,
|
||||
),
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=time_dimension,
|
||||
operator=Operator.LESS_THAN,
|
||||
value=query_object.inner_to_dttm,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Add fetch values predicate if present
|
||||
if (
|
||||
query_object.apply_fetch_values_predicate
|
||||
and query_object.datasource.fetch_values_predicate
|
||||
):
|
||||
filters.add(
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=None,
|
||||
operator=Operator.ADHOC,
|
||||
value=query_object.datasource.fetch_values_predicate,
|
||||
)
|
||||
)
|
||||
|
||||
# Add filters from query_object.extras (WHERE and HAVING clauses)
|
||||
extras_filters = _get_filters_from_extras(query_object.extras)
|
||||
filters.update(extras_filters)
|
||||
|
||||
# Add all other non-temporal filters from query_object.filter
|
||||
for filter_ in query_object.filter:
|
||||
# Skip temporal range filters - we're using inner bounds instead
|
||||
if (
|
||||
filter_.get("op") == FilterOperator.TEMPORAL_RANGE.value
|
||||
and query_object.granularity
|
||||
):
|
||||
continue
|
||||
|
||||
if converted_filters := _convert_query_object_filter(filter_, all_dimensions):
|
||||
filters.update(converted_filters)
|
||||
|
||||
return filters if filters else None
|
||||
|
||||
|
||||
def _convert_time_grain(time_grain: str) -> Grain | None:
|
||||
"""
|
||||
Convert a time grain string (ISO 8601 duration) to a Grain instance.
|
||||
"""
|
||||
try:
|
||||
return Grains.get(time_grain)
|
||||
except (ValueError, isodate.ISO8601Error):
|
||||
return None
|
||||
|
||||
|
||||
def validate_query_object(
|
||||
query_object: QueryObject,
|
||||
) -> TypeGuard[ValidatedQueryObject]:
|
||||
"""
|
||||
Validate that the `QueryObject` is compatible with the `SemanticView`.
|
||||
|
||||
If some semantic view implementation supports these features we should add an
|
||||
attribute to the `SemanticViewImplementation` to indicate support for them.
|
||||
"""
|
||||
if not query_object.datasource:
|
||||
return False
|
||||
|
||||
query_object = cast(ValidatedQueryObject, query_object)
|
||||
|
||||
_validate_metrics(query_object)
|
||||
_validate_dimensions(query_object)
|
||||
_validate_filters(query_object)
|
||||
_validate_granularity(query_object)
|
||||
_validate_group_limit(query_object)
|
||||
_validate_orderby(query_object)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _validate_metrics(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Make sure metrics are defined in the semantic view.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
if any(not isinstance(metric, str) for metric in (query_object.metrics or [])):
|
||||
raise ValueError("Adhoc metrics are not supported in Semantic Views.")
|
||||
|
||||
metric_names = {metric.name for metric in semantic_view.metrics}
|
||||
if not set(query_object.metrics or []) <= metric_names:
|
||||
raise ValueError("All metrics must be defined in the Semantic View.")
|
||||
|
||||
|
||||
def _validate_dimensions(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Make sure all dimensions are defined in the semantic view.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
|
||||
|
||||
# Normalize all columns to dimension names
|
||||
normalized_columns = [
|
||||
_normalize_column(column, dimension_names) for column in query_object.columns
|
||||
]
|
||||
|
||||
if not set(normalized_columns) <= dimension_names:
|
||||
raise ValueError("All dimensions must be defined in the Semantic View.")
|
||||
|
||||
|
||||
def _validate_filters(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Make sure all filters are valid.
|
||||
"""
|
||||
for filter_ in query_object.filter:
|
||||
if isinstance(filter_["col"], dict):
|
||||
raise ValueError(
|
||||
"Adhoc columns are not supported in Semantic View filters."
|
||||
)
|
||||
if not filter_.get("op"):
|
||||
raise ValueError("All filters must have an operator defined.")
|
||||
|
||||
|
||||
def _validate_granularity(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Make sure time column and time grain are valid.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
|
||||
|
||||
if time_column := query_object.granularity:
|
||||
if time_column not in dimension_names:
|
||||
raise ValueError(
|
||||
"The time column must be defined in the Semantic View dimensions."
|
||||
)
|
||||
|
||||
if time_grain := query_object.extras.get("time_grain_sqla"):
|
||||
if not time_column:
|
||||
raise ValueError(
|
||||
"A time column must be specified when a time grain is provided."
|
||||
)
|
||||
|
||||
supported_time_grains = {
|
||||
dimension.grain
|
||||
for dimension in semantic_view.dimensions
|
||||
if dimension.name == time_column and dimension.grain
|
||||
}
|
||||
if _convert_time_grain(time_grain) not in supported_time_grains:
|
||||
raise ValueError(
|
||||
"The time grain is not supported for the time column in the "
|
||||
"Semantic View."
|
||||
)
|
||||
|
||||
|
||||
def _validate_group_limit(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Validate group limit related features in the query object.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
# no limit
|
||||
if query_object.series_limit == 0:
|
||||
return
|
||||
|
||||
if (
|
||||
query_object.series_columns
|
||||
and SemanticViewFeature.GROUP_LIMIT not in semantic_view.features
|
||||
):
|
||||
raise ValueError("Group limit is not supported in this Semantic View.")
|
||||
|
||||
if any(not isinstance(col, str) for col in query_object.series_columns):
|
||||
raise ValueError("Adhoc dimensions are not supported in series columns.")
|
||||
|
||||
metric_names = {metric.name for metric in semantic_view.metrics}
|
||||
if query_object.series_limit_metric and (
|
||||
not isinstance(query_object.series_limit_metric, str)
|
||||
or query_object.series_limit_metric not in metric_names
|
||||
):
|
||||
raise ValueError(
|
||||
"The series limit metric must be defined in the Semantic View."
|
||||
)
|
||||
|
||||
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
|
||||
if not set(query_object.series_columns) <= dimension_names:
|
||||
raise ValueError("All series columns must be defined in the Semantic View.")
|
||||
|
||||
if (
|
||||
query_object.group_others_when_limit_reached
|
||||
and SemanticViewFeature.GROUP_OTHERS not in semantic_view.features
|
||||
):
|
||||
raise ValueError(
|
||||
"Grouping others when limit is reached is not supported in this Semantic "
|
||||
"View."
|
||||
)
|
||||
|
||||
|
||||
def _validate_orderby(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Validate order by elements in the query object.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
if (
|
||||
any(not isinstance(element, str) for element, _ in query_object.orderby)
|
||||
and SemanticViewFeature.ADHOC_EXPRESSIONS_IN_ORDERBY
|
||||
not in semantic_view.features
|
||||
):
|
||||
raise ValueError(
|
||||
"Adhoc expressions in order by are not supported in this Semantic View."
|
||||
)
|
||||
|
||||
elements = {orderby[0] for orderby in query_object.orderby}
|
||||
metric_names = {metric.name for metric in semantic_view.metrics}
|
||||
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
|
||||
if not elements <= metric_names | dimension_names:
|
||||
raise ValueError("All order by elements must be defined in the Semantic View.")
|
||||
@@ -0,0 +1,408 @@
|
||||
# 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.
|
||||
|
||||
"""Semantic layer models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Hashable
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
import pyarrow as pa
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy_utils import UUIDType
|
||||
from sqlalchemy_utils.types.json import JSONType
|
||||
from superset_core.semantic_layers.layer import (
|
||||
SemanticLayer as SemanticLayerABC,
|
||||
)
|
||||
from superset_core.semantic_layers.view import (
|
||||
SemanticView as SemanticViewABC,
|
||||
)
|
||||
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.explorables.base import TimeGrainDict
|
||||
from superset.extensions import encrypted_field_factory
|
||||
from superset.models.helpers import AuditMixinNullable, QueryResult
|
||||
from superset.semantic_layers.mapper import get_results
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.superset_typing import ExplorableData, QueryObjectDict
|
||||
|
||||
|
||||
def get_column_type(semantic_type: pa.DataType) -> GenericDataType:
|
||||
"""
|
||||
Map Arrow data types to generic data types.
|
||||
"""
|
||||
if pa.types.is_date(semantic_type) or pa.types.is_timestamp(semantic_type):
|
||||
return GenericDataType.TEMPORAL
|
||||
if pa.types.is_time(semantic_type):
|
||||
return GenericDataType.TEMPORAL
|
||||
if (
|
||||
pa.types.is_integer(semantic_type)
|
||||
or pa.types.is_floating(semantic_type)
|
||||
or pa.types.is_decimal(semantic_type)
|
||||
or pa.types.is_duration(semantic_type)
|
||||
):
|
||||
return GenericDataType.NUMERIC
|
||||
if pa.types.is_boolean(semantic_type):
|
||||
return GenericDataType.BOOLEAN
|
||||
return GenericDataType.STRING
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetricMetadata:
|
||||
metric_name: str
|
||||
expression: str
|
||||
verbose_name: str | None = None
|
||||
description: str | None = None
|
||||
d3format: str | None = None
|
||||
currency: dict[str, Any] | None = None
|
||||
warning_text: str | None = None
|
||||
certified_by: str | None = None
|
||||
certification_details: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ColumnMetadata:
|
||||
column_name: str
|
||||
type: str
|
||||
is_dttm: bool
|
||||
verbose_name: str | None = None
|
||||
description: str | None = None
|
||||
groupby: bool = True
|
||||
filterable: bool = True
|
||||
expression: str | None = None
|
||||
python_date_format: str | None = None
|
||||
advanced_data_type: str | None = None
|
||||
extra: str | None = None
|
||||
|
||||
|
||||
class SemanticLayer(AuditMixinNullable, Model):
|
||||
"""
|
||||
Semantic layer model.
|
||||
|
||||
A semantic layer provides an abstraction over data sources,
|
||||
allowing users to query data through a semantic interface.
|
||||
"""
|
||||
|
||||
__tablename__ = "semantic_layers"
|
||||
|
||||
uuid = Column(UUIDType(binary=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# Core fields
|
||||
name = Column(String(250), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
type = Column(String(250), nullable=False) # snowflake, etc
|
||||
|
||||
configuration = Column(encrypted_field_factory.create(JSONType), default="{}")
|
||||
# Tracks the schema version of the configuration JSON field to aid with
|
||||
# migrations as the configuration schema evolves over time.
|
||||
configuration_version = Column(Integer, nullable=False, default=1)
|
||||
cache_timeout = Column(Integer, nullable=True)
|
||||
|
||||
# Semantic views relationship
|
||||
semantic_views: list[SemanticView] = relationship(
|
||||
"SemanticView",
|
||||
back_populates="semantic_layer",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.name or str(self.uuid)
|
||||
|
||||
@cached_property
|
||||
def implementation(
|
||||
self,
|
||||
) -> SemanticLayerABC[Any, SemanticViewABC]:
|
||||
"""
|
||||
Return semantic layer implementation.
|
||||
"""
|
||||
# TODO (betodealmeida):
|
||||
# return extension_manager.get_contribution("semanticLayers", self.type)
|
||||
class_ = registry[self.type]
|
||||
return class_.from_configuration(json.loads(self.configuration))
|
||||
|
||||
|
||||
class SemanticView(AuditMixinNullable, Model):
|
||||
"""
|
||||
Semantic view model.
|
||||
|
||||
A semantic view represents a queryable view within a semantic layer.
|
||||
"""
|
||||
|
||||
__tablename__ = "semantic_views"
|
||||
|
||||
# Use integer as the primary key for cross-database auto-increment
|
||||
# compatibility (sa.Identity() is not supported in MySQL or SQLite).
|
||||
# The uuid column is a secondary unique identifier used in URLs and perms.
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
uuid = Column(UUIDType(binary=True), unique=True, default=uuid.uuid4)
|
||||
|
||||
# Core fields
|
||||
name = Column(String(250), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
configuration = Column(encrypted_field_factory.create(JSONType), default="{}")
|
||||
# Tracks the schema version of the configuration JSON field to aid with
|
||||
# migrations as the configuration schema evolves over time.
|
||||
configuration_version = Column(Integer, nullable=False, default=1)
|
||||
cache_timeout = Column(Integer, nullable=True)
|
||||
|
||||
# Semantic layer relationship
|
||||
semantic_layer_uuid = Column(
|
||||
UUIDType(binary=True),
|
||||
ForeignKey("semantic_layers.uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
semantic_layer: SemanticLayer = relationship(
|
||||
"SemanticLayer",
|
||||
back_populates="semantic_views",
|
||||
foreign_keys=[semantic_layer_uuid],
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.name or str(self.uuid)
|
||||
|
||||
@cached_property
|
||||
def implementation(self) -> SemanticViewABC:
|
||||
"""
|
||||
Return semantic view implementation.
|
||||
"""
|
||||
return self.semantic_layer.implementation.get_semantic_view(
|
||||
self.name,
|
||||
json.loads(self.configuration),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Explorable protocol implementation
|
||||
# =========================================================================
|
||||
|
||||
def get_query_result(self, query_object: QueryObject) -> QueryResult:
|
||||
return get_results(query_object)
|
||||
|
||||
def get_query_str(self, query_obj: QueryObjectDict) -> str:
|
||||
return "Not implemented for semantic layers"
|
||||
|
||||
@property
|
||||
def table_name(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return "semantic_view"
|
||||
|
||||
@property
|
||||
def uid(self) -> str:
|
||||
return self.implementation.uid()
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "semantic_view"
|
||||
|
||||
@property
|
||||
def metrics(self) -> list[MetricMetadata]:
|
||||
return [
|
||||
MetricMetadata(
|
||||
metric_name=metric.name,
|
||||
expression=metric.definition,
|
||||
description=metric.description,
|
||||
)
|
||||
for metric in self.implementation.get_metrics()
|
||||
]
|
||||
|
||||
@property
|
||||
def columns(self) -> list[ColumnMetadata]:
|
||||
return [
|
||||
ColumnMetadata(
|
||||
column_name=dimension.name,
|
||||
type=str(dimension.type),
|
||||
is_dttm=pa.types.is_date(dimension.type)
|
||||
or pa.types.is_time(dimension.type)
|
||||
or pa.types.is_timestamp(dimension.type),
|
||||
description=dimension.description,
|
||||
expression=dimension.definition,
|
||||
extra=json.dumps(
|
||||
{"grain": dimension.grain.name if dimension.grain else None}
|
||||
),
|
||||
)
|
||||
for dimension in self.implementation.get_dimensions()
|
||||
]
|
||||
|
||||
@property
|
||||
def column_names(self) -> list[str]:
|
||||
return [dimension.name for dimension in self.implementation.get_dimensions()]
|
||||
|
||||
@property
|
||||
def data(self) -> ExplorableData:
|
||||
return {
|
||||
# core
|
||||
"id": self.id,
|
||||
"uid": self.uid,
|
||||
"type": "semantic_view",
|
||||
"name": self.name,
|
||||
"columns": [
|
||||
{
|
||||
"advanced_data_type": None,
|
||||
"certification_details": None,
|
||||
"certified_by": None,
|
||||
"column_name": dimension.name,
|
||||
"description": dimension.description,
|
||||
"expression": dimension.definition,
|
||||
"filterable": True,
|
||||
"groupby": True,
|
||||
"id": None,
|
||||
"uuid": None,
|
||||
"is_certified": False,
|
||||
"is_dttm": pa.types.is_date(dimension.type)
|
||||
or pa.types.is_time(dimension.type)
|
||||
or pa.types.is_timestamp(dimension.type),
|
||||
"python_date_format": None,
|
||||
"type": str(dimension.type),
|
||||
"type_generic": get_column_type(dimension.type),
|
||||
"verbose_name": None,
|
||||
"warning_markdown": None,
|
||||
}
|
||||
for dimension in self.implementation.get_dimensions()
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"certification_details": None,
|
||||
"certified_by": None,
|
||||
"d3format": None,
|
||||
"description": metric.description,
|
||||
"expression": metric.definition,
|
||||
"id": None,
|
||||
"uuid": None,
|
||||
"is_certified": False,
|
||||
"metric_name": metric.name,
|
||||
"warning_markdown": None,
|
||||
"warning_text": None,
|
||||
"verbose_name": None,
|
||||
}
|
||||
for metric in self.implementation.get_metrics()
|
||||
],
|
||||
"database": {},
|
||||
# UI features
|
||||
"verbose_map": {},
|
||||
"order_by_choices": [],
|
||||
"filter_select": True,
|
||||
"filter_select_enabled": True,
|
||||
"sql": None,
|
||||
"select_star": None,
|
||||
"owners": [],
|
||||
"description": self.description,
|
||||
"table_name": self.name,
|
||||
"column_types": [
|
||||
get_column_type(dimension.type)
|
||||
for dimension in self.implementation.get_dimensions()
|
||||
],
|
||||
"column_names": [
|
||||
dimension.name for dimension in self.implementation.get_dimensions()
|
||||
],
|
||||
# rare
|
||||
"column_formats": {},
|
||||
"datasource_name": self.name,
|
||||
"perm": self.perm,
|
||||
"offset": self.offset,
|
||||
"cache_timeout": self.cache_timeout,
|
||||
"params": None,
|
||||
# sql-specific
|
||||
"schema": None,
|
||||
"catalog": None,
|
||||
"main_dttm_col": None,
|
||||
"time_grain_sqla": [],
|
||||
"granularity_sqla": [],
|
||||
"fetch_values_predicate": None,
|
||||
"template_params": None,
|
||||
"is_sqllab_view": False,
|
||||
"extra": None,
|
||||
"always_filter_main_dttm": False,
|
||||
"normalize_columns": False,
|
||||
"edit_url": "",
|
||||
"default_endpoint": None,
|
||||
"folders": [],
|
||||
"health_check_message": None,
|
||||
}
|
||||
|
||||
def data_for_slices(self, slices: list[Any]) -> ExplorableData:
|
||||
return self.data
|
||||
|
||||
def get_extra_cache_keys(self, query_obj: QueryObjectDict) -> list[Hashable]:
|
||||
return []
|
||||
|
||||
@property
|
||||
def perm(self) -> str:
|
||||
return self.semantic_layer_uuid.hex + "::" + self.uuid.hex
|
||||
|
||||
@property
|
||||
def catalog_perm(self) -> str | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def schema_perm(self) -> str | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def schema(self) -> str | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"/semantic_view/{self.uuid}/"
|
||||
|
||||
@property
|
||||
def explore_url(self) -> str:
|
||||
return f"/explore/?datasource_type=semantic_view&datasource_id={self.id}"
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
# always return datetime as UTC
|
||||
return 0
|
||||
|
||||
def get_time_grains(self) -> list[TimeGrainDict]:
|
||||
return [
|
||||
{
|
||||
"name": dimension.grain.name,
|
||||
"function": "",
|
||||
"duration": dimension.grain.representation,
|
||||
}
|
||||
for dimension in self.implementation.get_dimensions()
|
||||
if dimension.grain
|
||||
]
|
||||
|
||||
def has_drill_by_columns(self, column_names: list[str]) -> bool:
|
||||
dimension_names = {
|
||||
dimension.name for dimension in self.implementation.get_dimensions()
|
||||
}
|
||||
return all(column_name in dimension_names for column_name in column_names)
|
||||
|
||||
@property
|
||||
def is_rls_supported(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def query_language(self) -> str | None:
|
||||
return None
|
||||
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from superset_core.semantic_layers.layer import SemanticLayer
|
||||
|
||||
registry: dict[str, type[SemanticLayer[Any, Any]]] = {}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from marshmallow import fields, Schema
|
||||
|
||||
|
||||
class SemanticViewPutSchema(Schema):
|
||||
description = fields.String(allow_none=True)
|
||||
cache_timeout = fields.Integer(allow_none=True)
|
||||
|
||||
|
||||
class SemanticLayerPostSchema(Schema):
|
||||
name = fields.String(required=True)
|
||||
description = fields.String(allow_none=True)
|
||||
type = fields.String(required=True)
|
||||
configuration = fields.Dict(required=True)
|
||||
cache_timeout = fields.Integer(allow_none=True)
|
||||
|
||||
|
||||
class SemanticLayerPutSchema(Schema):
|
||||
name = fields.String()
|
||||
description = fields.String(allow_none=True)
|
||||
configuration = fields.Dict()
|
||||
cache_timeout = fields.Integer(allow_none=True)
|
||||
@@ -30,6 +30,46 @@ if TYPE_CHECKING:
|
||||
SQLType: TypeAlias = TypeEngine | type[TypeEngine]
|
||||
|
||||
|
||||
class DatasetColumnData(TypedDict, total=False):
|
||||
"""Type for column metadata in ExplorableData datasets."""
|
||||
|
||||
advanced_data_type: str | None
|
||||
certification_details: str | None
|
||||
certified_by: str | None
|
||||
column_name: str
|
||||
description: str | None
|
||||
expression: str | None
|
||||
filterable: bool
|
||||
groupby: bool
|
||||
id: int | None
|
||||
uuid: str | None
|
||||
is_certified: bool
|
||||
is_dttm: bool
|
||||
python_date_format: str | None
|
||||
type: str
|
||||
type_generic: NotRequired["GenericDataType" | None]
|
||||
verbose_name: str | None
|
||||
warning_markdown: str | None
|
||||
|
||||
|
||||
class DatasetMetricData(TypedDict, total=False):
|
||||
"""Type for metric metadata in ExplorableData datasets."""
|
||||
|
||||
certification_details: str | None
|
||||
certified_by: str | None
|
||||
currency: NotRequired[dict[str, Any]]
|
||||
d3format: str | None
|
||||
description: str | None
|
||||
expression: str | None
|
||||
id: int | None
|
||||
uuid: str | None
|
||||
is_certified: bool
|
||||
metric_name: str
|
||||
warning_markdown: str | None
|
||||
warning_text: str | None
|
||||
verbose_name: str | None
|
||||
|
||||
|
||||
class LegacyMetric(TypedDict):
|
||||
label: str | None
|
||||
|
||||
@@ -254,7 +294,7 @@ class ExplorableData(TypedDict, total=False):
|
||||
"""
|
||||
|
||||
# Core fields from BaseDatasource.data
|
||||
id: int
|
||||
id: int | str # String for UUID-based explorables like SemanticView
|
||||
uid: str
|
||||
column_formats: dict[str, str | None]
|
||||
description: str | None
|
||||
@@ -274,8 +314,8 @@ class ExplorableData(TypedDict, total=False):
|
||||
perm: str | None
|
||||
edit_url: str
|
||||
sql: str | None
|
||||
columns: list[dict[str, Any]]
|
||||
metrics: list[dict[str, Any]]
|
||||
columns: list["DatasetColumnData"]
|
||||
metrics: list["DatasetMetricData"]
|
||||
folders: Any # JSON field, can be list or dict
|
||||
order_by_choices: list[tuple[str, str]]
|
||||
owners: list[int] | list[dict[str, Any]] # Can be either format
|
||||
@@ -283,8 +323,8 @@ class ExplorableData(TypedDict, total=False):
|
||||
select_star: str | None
|
||||
|
||||
# Additional fields from SqlaTable and data_for_slices
|
||||
column_types: list[Any]
|
||||
column_names: set[str] | set[Any]
|
||||
column_types: list["GenericDataType"]
|
||||
column_names: set[str] | list[str]
|
||||
granularity_sqla: list[tuple[Any, Any]]
|
||||
time_grain_sqla: list[tuple[Any, Any]]
|
||||
main_dttm_col: str | None
|
||||
|
||||
@@ -10547,7 +10547,7 @@ msgstr "قيد التشغيل"
|
||||
|
||||
#, fuzzy, python-format
|
||||
msgid "Running block %(block_num)s out of %(block_count)s"
|
||||
msgstr "جاري تشغيل البيان %(statement_num)s من %(statement_count)s"
|
||||
msgstr "جاري تشغيل البيان %(block_num)s من %(block_count)s"
|
||||
|
||||
msgid "SAT"
|
||||
msgstr "جلس"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user