mirror of
https://github.com/apache/superset.git
synced 2026-07-13 18:25:58 +00:00
Compare commits
25 Commits
backup/sem
...
backup/sem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808d905c9e | ||
|
|
152f11d1d4 | ||
|
|
b2784f297c | ||
|
|
0e249e6348 | ||
|
|
ab52bd14c4 | ||
|
|
5b4035427d | ||
|
|
07712fbd20 | ||
|
|
580243d84a | ||
|
|
4827fbb615 | ||
|
|
1d4f31c4df | ||
|
|
bf71d49bd6 | ||
|
|
47973bd789 | ||
|
|
1b089f828a | ||
|
|
3c20d62ed2 | ||
|
|
cc15ffd044 | ||
|
|
1f43b3fa0e | ||
|
|
fc5a3cd32b | ||
|
|
4490bffd50 | ||
|
|
a1a05134d2 | ||
|
|
8c69f13a71 | ||
|
|
4c645ce952 | ||
|
|
379403834a | ||
|
|
469acf12b2 | ||
|
|
bc58d13c34 | ||
|
|
ebbf4777e3 |
@@ -105,7 +105,13 @@ class CeleryConfig:
|
||||
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
|
||||
FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATASET_FOLDERS": True}
|
||||
FEATURE_FLAGS = {
|
||||
"ALERT_REPORTS": True,
|
||||
"DATASET_FOLDERS": True,
|
||||
"ENABLE_EXTENSIONS": True,
|
||||
"SEMANTIC_LAYERS": True,
|
||||
}
|
||||
EXTENSIONS_PATH = "/app/docker/extensions"
|
||||
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
|
||||
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
|
||||
# The base URL for the email report hyperlinks.
|
||||
|
||||
@@ -285,6 +285,7 @@ module = [
|
||||
"superset.tags.filters",
|
||||
"superset.commands.security.update",
|
||||
"superset.commands.security.create",
|
||||
"superset.semantic_layers.api",
|
||||
]
|
||||
warn_unused_ignores = false
|
||||
|
||||
|
||||
73
superset-core/src/superset_core/semantic_layers/config.py
Normal file
73
superset-core/src/superset_core/semantic_layers/config.py
Normal file
@@ -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)
|
||||
@@ -32,6 +32,8 @@ class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]):
|
||||
Abstract base class for semantic layers.
|
||||
"""
|
||||
|
||||
configuration_class: type[BaseModel]
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def from_configuration(
|
||||
|
||||
@@ -47,6 +47,11 @@ class SemanticView(ABC):
|
||||
|
||||
features: frozenset[SemanticViewFeature]
|
||||
|
||||
# Implementations must expose a display name for the view.
|
||||
# Declared here as a type annotation (not abstract) so that existing
|
||||
# implementations are not required to add a formal @abstractmethod.
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def uid(self) -> str:
|
||||
"""
|
||||
|
||||
215
superset-frontend/package-lock.json
generated
215
superset-frontend/package-lock.json
generated
@@ -28,8 +28,14 @@
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@googleapis/sheets": "^13.0.1",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.7.0",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.5",
|
||||
@@ -37,6 +43,7 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@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",
|
||||
@@ -4042,6 +4049,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/fira-code": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz",
|
||||
"integrity": "sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/ibm-plex-mono": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz",
|
||||
@@ -4052,11 +4068,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.6.tgz",
|
||||
"integrity": "sha512-CZs9S1CrjD0jPwsNy9W6j0BhsmRSQrgwlTNkgQXTsAeDRM42LBRLo3eo9gCzfH4GvV7zpyf78Ozfl773826csw==",
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||
"license": "OFL-1.1",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
@@ -4073,6 +4088,26 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@great-expectations/jsonforms-antd-renderers": {
|
||||
"version": "2.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@great-expectations/jsonforms-antd-renderers/-/jsonforms-antd-renderers-2.2.11.tgz",
|
||||
"integrity": "sha512-QeKI6RP+vZo5Bf5WX5Mx6CPEBYvR83bIyeezHoyVVc1+pGsDqO9lsFdbaFKpqozV+s/TRB1KmVAW4GxpMzLuAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.isempty": "^4.4.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.range": "^3.2.0",
|
||||
"lodash.startcase": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.3.0",
|
||||
"@jsonforms/core": "^3.3.0",
|
||||
"@jsonforms/react": "^3.3.0",
|
||||
"antd": "^5.14.0",
|
||||
"dayjs": "^1",
|
||||
"react": "^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/address": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
|
||||
@@ -6120,6 +6155,45 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/core": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.7.0.tgz",
|
||||
"integrity": "sha512-CE9viWtwi9QWLqlWLeOul1/R1GRAyOA9y6OoUpsCc0FhyR+g5p29F3k0fUExHWxL0Sf4KHcXYkfhtqfRBPS8ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.3",
|
||||
"ajv": "^8.6.1",
|
||||
"ajv-formats": "^2.1.0",
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/react": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.7.0.tgz",
|
||||
"integrity": "sha512-HkY7qAx8vW97wPEgZ7GxCB3iiXG1c95GuObxtcDHGPBJWMwnxWBnVYJmv5h7nthrInKsQKHZL5OusnC/sj/1GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jsonforms/core": "3.7.0",
|
||||
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/vanilla-renderers": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.7.0.tgz",
|
||||
"integrity": "sha512-RdXQGsheARUJVbaTe6SqGw9W4/yrm0BgUok6OKUj8krp1NF4fqXc5UbYGHFksMR/p7LCuoYHCtQzKLXEfxJbDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jsonforms/core": "3.7.0",
|
||||
"@jsonforms/react": "3.7.0",
|
||||
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonjoy.com/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz",
|
||||
@@ -9487,6 +9561,89 @@
|
||||
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rjsf/antd": {
|
||||
"version": "5.24.13",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/antd/-/antd-5.24.13.tgz",
|
||||
"integrity": "sha512-UiWE8xoBxxCoe/SEkdQEmL5E6z3I1pw0+y0dTyGt8SHfAxxFc4/OWn7tKOAiNsKCXgf83t0JKn6CHWLD01sAdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"rc-picker": "2.7.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^4.0.0 || ^5.0.0",
|
||||
"@rjsf/core": "^5.24.x",
|
||||
"@rjsf/utils": "^5.24.x",
|
||||
"antd": "^4.24.0 || ^5.8.5",
|
||||
"dayjs": "^1.8.0",
|
||||
"react": "^16.14.0 || >=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/antd/node_modules/rc-picker": {
|
||||
"version": "2.7.6",
|
||||
"resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-2.7.6.tgz",
|
||||
"integrity": "sha512-H9if/BUJUZBOhPfWcPeT15JUI3/ntrG9muzERrXDkSoWmDj4yzmBvumozpxYrHwjcKnjyDGAke68d+whWwvhHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.10.1",
|
||||
"classnames": "^2.2.1",
|
||||
"date-fns": "2.x",
|
||||
"dayjs": "1.x",
|
||||
"moment": "^2.24.0",
|
||||
"rc-trigger": "^5.0.4",
|
||||
"rc-util": "^5.37.0",
|
||||
"shallowequal": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/antd/node_modules/rc-picker/node_modules/rc-trigger": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz",
|
||||
"integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"classnames": "^2.2.6",
|
||||
"rc-align": "^4.0.0",
|
||||
"rc-motion": "^2.0.0",
|
||||
"rc-util": "^5.19.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/antd/node_modules/rc-picker/node_modules/rc-trigger/node_modules/rc-align": {
|
||||
"version": "4.0.15",
|
||||
"resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz",
|
||||
"integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.10.1",
|
||||
"classnames": "2.x",
|
||||
"dom-align": "^1.7.0",
|
||||
"rc-util": "^5.26.0",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/core": {
|
||||
"version": "5.24.13",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.13.tgz",
|
||||
@@ -22435,6 +22592,22 @@
|
||||
"integrity": "sha512-O/gRkjWULp3xVX8K85V0H3tsSGole0WYt77KVpGZO2xTGLuVFuvE6JIsIli3fvFHCYBhGFn/8OHEEyMYF+QehA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.2.tgz",
|
||||
@@ -23040,6 +23213,12 @@
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-align": {
|
||||
"version": "1.12.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
|
||||
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||
@@ -35677,6 +35856,12 @@
|
||||
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isempty": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
|
||||
"integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
@@ -35708,7 +35893,6 @@
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
@@ -35719,6 +35903,18 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.range": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.range/-/lodash.range-3.2.0.tgz",
|
||||
"integrity": "sha512-Fgkb7SinmuzqgIhNhAElo0BL/R1rHCnhwSZf78omqSwvWqD0kD2ssOAutQonDKH/ldS8BxA72ORYI09qAY9CYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.startcase": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz",
|
||||
"integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.uniq": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||
@@ -37240,7 +37436,6 @@
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
@@ -44766,6 +44961,12 @@
|
||||
"integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shapefile": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/shapefile/-/shapefile-0.3.1.tgz",
|
||||
@@ -51505,7 +51706,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface Datasource {
|
||||
id: number;
|
||||
name: string;
|
||||
type: DatasourceType;
|
||||
/**
|
||||
* The parent resource that owns this datasource.
|
||||
* For SQL-based datasets this is the database; for semantic views it is the
|
||||
* semantic layer. Use this field instead of the legacy `database` field when
|
||||
* you only need the display name.
|
||||
*/
|
||||
parent?: { name: string };
|
||||
columns: Column[];
|
||||
metrics: Metric[];
|
||||
description?: string;
|
||||
|
||||
@@ -361,7 +361,9 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
width,
|
||||
} = this.props;
|
||||
|
||||
const databaseName = datasource?.database?.name as string | undefined;
|
||||
const databaseName =
|
||||
datasource?.parent?.name ??
|
||||
(datasource?.database?.name as string | undefined);
|
||||
|
||||
const isLoading = chartStatus === 'loading';
|
||||
// Suppress spinner during auto-refresh to avoid visual flicker
|
||||
|
||||
@@ -53,6 +53,7 @@ import { Dataset } from '../types';
|
||||
import TableControls from './DrillDetailTableControls';
|
||||
import { getDrillPayload } from './utils';
|
||||
import { ResultsPage } from './types';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -303,7 +304,7 @@ export default function DrillDetailPane({
|
||||
tableContent = <Loading />;
|
||||
} else if (resultsPage?.total === 0) {
|
||||
// Render empty state if no results are returned for page
|
||||
const title = t('No rows were returned for this dataset');
|
||||
const title = t('No rows were returned for this %s', datasetLabelLower());
|
||||
tableContent = <EmptyState image="document.svg" title={title} />;
|
||||
} else {
|
||||
// Render table if at least one page has successfully loaded
|
||||
|
||||
@@ -52,6 +52,10 @@ import type {
|
||||
DatabaseObject,
|
||||
} from './types';
|
||||
import { StyledFormLabel } from './styles';
|
||||
import {
|
||||
databaseLabel,
|
||||
databasesLabelLower,
|
||||
} from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const DatabaseSelectorWrapper = styled.div<{ horizontal?: boolean }>`
|
||||
${({ theme, horizontal }) =>
|
||||
@@ -431,7 +435,11 @@ export function DatabaseSelector({
|
||||
function renderDatabaseSelect() {
|
||||
if (sqlLabMode) {
|
||||
return renderSelectRow(
|
||||
t('Select database or type to search databases'),
|
||||
t(
|
||||
'Select %s or type to search %s',
|
||||
databaseLabel().toLowerCase(),
|
||||
databasesLabelLower(),
|
||||
),
|
||||
null,
|
||||
null,
|
||||
{
|
||||
@@ -448,16 +456,24 @@ export function DatabaseSelector({
|
||||
return (
|
||||
<div>
|
||||
{renderSelectRow(
|
||||
t('Database'),
|
||||
databaseLabel(),
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Select database or type to search databases')}
|
||||
ariaLabel={t(
|
||||
'Select %s or type to search %s',
|
||||
databaseLabel().toLowerCase(),
|
||||
databasesLabelLower(),
|
||||
)}
|
||||
optionFilterProps={['database_name', 'value']}
|
||||
data-test="select-database"
|
||||
lazyLoading={false}
|
||||
notFoundContent={emptyState}
|
||||
onChange={changeDatabase}
|
||||
value={currentDb}
|
||||
placeholder={t('Select database or type to search databases')}
|
||||
placeholder={t(
|
||||
'Select %s or type to search %s',
|
||||
databaseLabel().toLowerCase(),
|
||||
databasesLabelLower(),
|
||||
)}
|
||||
disabled={!isDatabaseSelectEnabled || readOnly}
|
||||
options={loadDatabases}
|
||||
sortComparator={sortComparator}
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { InputRef } from 'antd';
|
||||
import type { Datasource, ChangeDatasourceModalProps } from '../types';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const CONFIRM_WARNING_MESSAGE = t(
|
||||
'Warning! Changing the dataset may break the chart if the metadata does not exist.',
|
||||
@@ -109,7 +110,11 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
|
||||
const {
|
||||
state: { loading, resourceCollection, resourceCount },
|
||||
fetchData,
|
||||
} = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
|
||||
} = useListViewResource<Dataset>(
|
||||
'dataset',
|
||||
datasetLabelLower(),
|
||||
addDangerToast,
|
||||
);
|
||||
|
||||
const selectDatasource = useCallback((datasource: Datasource) => {
|
||||
setConfirmChange(true);
|
||||
@@ -187,7 +192,7 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
|
||||
);
|
||||
});
|
||||
onHide();
|
||||
addSuccessToast(t('Successfully changed dataset!'));
|
||||
addSuccessToast(t('Successfully changed %s!', datasetLabelLower()));
|
||||
};
|
||||
|
||||
const handlerCancelConfirm = () => {
|
||||
@@ -253,7 +258,7 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
|
||||
onHide={onHide}
|
||||
responsive
|
||||
name="Swap dataset"
|
||||
title={t('Swap dataset')}
|
||||
title={t('Swap %s', datasetLabelLower())}
|
||||
width={confirmChange ? '432px' : ''}
|
||||
height={confirmChange ? 'auto' : '540px'}
|
||||
hideFooter={!confirmChange}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
|
||||
import type { ErrorMessageComponentProps } from './types';
|
||||
import { ErrorAlert } from './ErrorAlert';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
export function DatasetNotFoundErrorMessage({
|
||||
error,
|
||||
@@ -29,7 +30,7 @@ export function DatasetNotFoundErrorMessage({
|
||||
const { level, message } = error;
|
||||
return (
|
||||
<ErrorAlert
|
||||
errorType={t('Missing dataset')}
|
||||
errorType={t('Missing %s', datasetLabelLower())}
|
||||
message={subtitle}
|
||||
description={message}
|
||||
type={level}
|
||||
|
||||
@@ -60,6 +60,12 @@ function UIFilters(
|
||||
filter.current?.clearFilter?.();
|
||||
});
|
||||
},
|
||||
clearFilterById: (id: string) => {
|
||||
const index = filters.findIndex(f => f.id === id);
|
||||
if (index >= 0) {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { useCallback, useEffect, useRef, useState, ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import TableCollection from '@superset-ui/core/components/TableCollection';
|
||||
import BulkTagModal from 'src/features/tags/BulkTagModal';
|
||||
@@ -264,6 +264,11 @@ export interface ListViewProps<T extends object = any> {
|
||||
columnsForWrapText?: string[];
|
||||
enableBulkTag?: boolean;
|
||||
bulkTagResourceName?: string;
|
||||
/** Optional ref exposed to callers for programmatic filter control. */
|
||||
filtersRef?: React.RefObject<{
|
||||
clearFilters: () => void;
|
||||
clearFilterById: (id: string) => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ListView<T extends object = any>({
|
||||
@@ -290,6 +295,7 @@ export function ListView<T extends object = any>({
|
||||
columnsForWrapText,
|
||||
enableBulkTag = false,
|
||||
bulkTagResourceName,
|
||||
filtersRef,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
}: ListViewProps<T>) {
|
||||
@@ -337,7 +343,20 @@ export function ListView<T extends object = any>({
|
||||
});
|
||||
}
|
||||
|
||||
const filterControlsRef = useRef<{ clearFilters: () => void }>(null);
|
||||
const filterControlsRef = useRef<{
|
||||
clearFilters: () => void;
|
||||
clearFilterById: (id: string) => void;
|
||||
}>(null);
|
||||
|
||||
// Wire the optional external filtersRef to our internal filterControlsRef.
|
||||
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
|
||||
// ref is populated before the first paint and after every update.
|
||||
useLayoutEffect(() => {
|
||||
if (filtersRef) {
|
||||
(filtersRef as React.MutableRefObject<typeof filterControlsRef.current>).current =
|
||||
filterControlsRef.current;
|
||||
}
|
||||
});
|
||||
|
||||
const handleClearFilterControls = useCallback(() => {
|
||||
if (query.filters) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Tooltip, ImageLoader } from '@superset-ui/core/components';
|
||||
import { GenericLink, usePluginContext } from 'src/components';
|
||||
import { assetUrl } from 'src/utils/assetUrl';
|
||||
import { Theme } from '@emotion/react';
|
||||
import { datasetLabel } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const FALLBACK_THUMBNAIL_URL = assetUrl(
|
||||
'/static/assets/images/chart-card-fallback.svg',
|
||||
@@ -283,7 +284,7 @@ const AddSliceCard: FC<{
|
||||
>
|
||||
<MetadataItem label={t('Viz type')} value={vizName} />
|
||||
<MetadataItem
|
||||
label={t('Dataset')}
|
||||
label={datasetLabel()}
|
||||
value={
|
||||
datasourceUrl ? (
|
||||
<GenericLink to={datasourceUrl}>
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { ConnectDragSource } from 'react-dnd';
|
||||
import AddSliceCard from './AddSliceCard';
|
||||
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
|
||||
import { DragDroppable } from './dnd/DragDroppable';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
export type SliceAdderProps = {
|
||||
theme: Theme;
|
||||
@@ -88,7 +89,7 @@ const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
|
||||
const KEYS_TO_SORT = {
|
||||
slice_name: t('name'),
|
||||
viz_type: t('viz type'),
|
||||
datasource_name: t('dataset'),
|
||||
datasource_name: datasetLabelLower(),
|
||||
changed_on: t('recent'),
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
|
||||
import { dispatchChartCustomizationHoverAction } from './utils';
|
||||
import { mergeExtraFormData } from '../../utils';
|
||||
import {
|
||||
datasetLabel as getDatasetLabel,
|
||||
datasetLabelLower,
|
||||
} from 'src/utils/semanticLayerLabels';
|
||||
|
||||
interface ColumnApiResponse {
|
||||
column_name?: string;
|
||||
@@ -262,7 +266,7 @@ const GroupByFilterCardContent: FC<{
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<RowLabel>{t('Dataset')}</RowLabel>
|
||||
<RowLabel>{getDatasetLabel()}</RowLabel>
|
||||
<RowValue>
|
||||
{typeof datasetLabel === 'string' ? datasetLabel : 'Dataset'}
|
||||
</RowValue>
|
||||
@@ -475,7 +479,13 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
|
||||
} catch (error) {
|
||||
setColumnOptions([]);
|
||||
dispatch(
|
||||
addDangerToast(t('Failed to load columns for dataset %s', datasetId)),
|
||||
addDangerToast(
|
||||
t(
|
||||
'Failed to load columns for %s %s',
|
||||
datasetLabelLower(),
|
||||
datasetId,
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
Dataset,
|
||||
DatasetSelectLabel,
|
||||
} from 'src/features/datasets/DatasetSelectLabel';
|
||||
import {
|
||||
datasetLabel,
|
||||
datasetLabelLower,
|
||||
datasetsLabelLower,
|
||||
} from 'src/utils/semanticLayerLabels';
|
||||
|
||||
interface DatasetSelectProps {
|
||||
onChange: (value: { label: string | ReactNode; value: number }) => void;
|
||||
@@ -101,13 +106,13 @@ const DatasetSelect = ({
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Dataset')}
|
||||
ariaLabel={datasetLabel()}
|
||||
value={value}
|
||||
options={loadDatasetOptionsCallback}
|
||||
onChange={onChange}
|
||||
optionFilterProps={['table_name']}
|
||||
notFoundContent={t('No compatible datasets found')}
|
||||
placeholder={t('Select a dataset')}
|
||||
notFoundContent={t('No compatible %s found', datasetsLabelLower())}
|
||||
placeholder={t('Select a %s', datasetLabelLower())}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
INPUT_WIDTH,
|
||||
} from './constants';
|
||||
import DependencyList from './DependencyList';
|
||||
import { datasetLabel } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const FORM_ITEM_WIDTH = 260;
|
||||
|
||||
@@ -972,7 +973,7 @@ const FiltersConfigForm = (
|
||||
<StyledFormItem
|
||||
expanded={expanded}
|
||||
name={['filters', filterId, 'dataset']}
|
||||
label={<StyledLabel>{t('Dataset')}</StyledLabel>}
|
||||
label={<StyledLabel>{datasetLabel()}</StyledLabel>}
|
||||
initialValue={
|
||||
datasetDetails
|
||||
? {
|
||||
@@ -992,7 +993,7 @@ const FiltersConfigForm = (
|
||||
rules={[
|
||||
{
|
||||
required: !isRemoved,
|
||||
message: t('Dataset is required'),
|
||||
message: t('%s is required', datasetLabel()),
|
||||
},
|
||||
]}
|
||||
{...getFiltersConfigModalTestId('datasource-input')}
|
||||
@@ -1018,7 +1019,7 @@ const FiltersConfigForm = (
|
||||
) : (
|
||||
<StyledFormItem
|
||||
expanded={expanded}
|
||||
label={<StyledLabel>{t('Dataset')}</StyledLabel>}
|
||||
label={<StyledLabel>{datasetLabel()}</StyledLabel>}
|
||||
>
|
||||
<Loading position="inline-centered" />
|
||||
</StyledFormItem>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import {
|
||||
TableView,
|
||||
@@ -135,7 +136,10 @@ export const SamplesPane = ({
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
const title = t('No samples were returned for this dataset');
|
||||
const title = t(
|
||||
'No samples were returned for this %s',
|
||||
datasetLabelLower(),
|
||||
);
|
||||
return <EmptyState image="document.svg" title={title} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,13 @@ import {
|
||||
DatasourceModal,
|
||||
ErrorAlert,
|
||||
} from 'src/components';
|
||||
import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
import {
|
||||
userHasPermission,
|
||||
isUserAdmin,
|
||||
@@ -68,6 +70,7 @@ interface ExtendedDatasource extends Datasource {
|
||||
}>;
|
||||
extra?: string;
|
||||
health_check_message?: string;
|
||||
cache_timeout?: number | null;
|
||||
database?: {
|
||||
id: number;
|
||||
database_name: string;
|
||||
@@ -375,7 +378,7 @@ class DatasourceControl extends PureComponent<
|
||||
|
||||
const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access');
|
||||
|
||||
const editText = t('Edit dataset');
|
||||
const editText = t('Edit %s', datasetLabelLower());
|
||||
const requestedQuery = {
|
||||
datasourceKey: `${datasource.id}__${datasource.type}`,
|
||||
sql: datasource.sql,
|
||||
@@ -387,7 +390,9 @@ class DatasourceControl extends PureComponent<
|
||||
label: !allowEdit ? (
|
||||
<Tooltip
|
||||
title={t(
|
||||
'You must be a dataset owner in order to edit. Please reach out to a dataset owner to request modifications or edit access.',
|
||||
'You must be a %s owner in order to edit. Please reach out to a %s owner to request modifications or edit access.',
|
||||
datasetLabelLower(),
|
||||
datasetLabelLower(),
|
||||
)}
|
||||
>
|
||||
{editText}
|
||||
@@ -402,7 +407,7 @@ class DatasourceControl extends PureComponent<
|
||||
|
||||
defaultDatasourceMenuItems.push({
|
||||
key: CHANGE_DATASET,
|
||||
label: t('Swap dataset'),
|
||||
label: t('Swap %s', datasetLabelLower()),
|
||||
});
|
||||
|
||||
if (!isMissingDatasource && canAccessSqlLab) {
|
||||
@@ -481,7 +486,7 @@ class DatasourceControl extends PureComponent<
|
||||
|
||||
queryDatasourceMenuItems.push({
|
||||
key: SAVE_AS_DATASET,
|
||||
label: <span>{t('Save as dataset')}</span>,
|
||||
label: <span>{t('Save as %s', datasetLabelLower())}</span>,
|
||||
});
|
||||
|
||||
const queryDatasourceMenu = (
|
||||
@@ -495,7 +500,7 @@ class DatasourceControl extends PureComponent<
|
||||
|
||||
const titleText =
|
||||
isMissingDatasource && !datasource.name
|
||||
? t('Missing dataset')
|
||||
? t('Missing %s', datasetLabelLower())
|
||||
: getDatasourceTitle(datasource);
|
||||
|
||||
const tooltip = titleText;
|
||||
@@ -561,14 +566,15 @@ class DatasourceControl extends PureComponent<
|
||||
) : (
|
||||
<ErrorAlert
|
||||
type="warning"
|
||||
message={t('Missing dataset')}
|
||||
message={t('Missing %s', datasetLabelLower())}
|
||||
descriptionPre={false}
|
||||
descriptionDetailsCollapsed={false}
|
||||
descriptionDetails={
|
||||
<>
|
||||
<p>
|
||||
{t(
|
||||
'The dataset linked to this chart may have been deleted.',
|
||||
'The %s linked to this chart may have been deleted.',
|
||||
datasetLabelLower(),
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
@@ -578,7 +584,7 @@ class DatasourceControl extends PureComponent<
|
||||
this.handleMenuItemClick({ key: CHANGE_DATASET })
|
||||
}
|
||||
>
|
||||
{t('Swap dataset')}
|
||||
{t('Swap %s', datasetLabelLower())}
|
||||
</Button>
|
||||
</p>
|
||||
</>
|
||||
@@ -587,14 +593,31 @@ class DatasourceControl extends PureComponent<
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showEditDatasourceModal && (
|
||||
<DatasourceModal
|
||||
datasource={datasource}
|
||||
show={showEditDatasourceModal}
|
||||
onDatasourceSave={this.onDatasourceSave}
|
||||
onHide={this.toggleEditDatasourceModal}
|
||||
/>
|
||||
)}
|
||||
{showEditDatasourceModal &&
|
||||
(datasource.type === DatasourceType.SemanticView ? (
|
||||
<SemanticViewEditModal
|
||||
show={showEditDatasourceModal}
|
||||
onHide={this.toggleEditDatasourceModal}
|
||||
onSave={() => {
|
||||
if (this.props.onDatasourceSave) {
|
||||
this.props.onDatasourceSave(datasource);
|
||||
}
|
||||
}}
|
||||
semanticView={{
|
||||
id: datasource.id,
|
||||
table_name: datasource.name,
|
||||
description: datasource.description,
|
||||
cache_timeout: datasource.cache_timeout,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DatasourceModal
|
||||
datasource={datasource}
|
||||
show={showEditDatasourceModal}
|
||||
onDatasourceSave={this.onDatasourceSave}
|
||||
onHide={this.toggleEditDatasourceModal}
|
||||
/>
|
||||
))}
|
||||
{showChangeDatasourceModal && (
|
||||
<ChangeDatasourceModal
|
||||
onDatasourceSave={this.onDatasourceSave}
|
||||
|
||||
@@ -23,6 +23,7 @@ import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilt
|
||||
import { OptionSortType } from 'src/explore/types';
|
||||
import { useGetTimeRangeLabel } from 'src/explore/components/controls/FilterControl/utils';
|
||||
import OptionWrapper from './OptionWrapper';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
export interface DndAdhocFilterOptionProps {
|
||||
adhocFilter: AdhocFilter;
|
||||
@@ -68,7 +69,10 @@ export default function DndAdhocFilterOption({
|
||||
isExtra={adhocFilter.isExtra}
|
||||
datasourceWarningMessage={
|
||||
adhocFilter.datasourceWarning
|
||||
? t('This filter might be incompatible with current dataset')
|
||||
? t(
|
||||
'This filter might be incompatible with current %s',
|
||||
datasetLabelLower(),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetr
|
||||
import MetricDefinitionValue from 'src/explore/components/controls/MetricControl/MetricDefinitionValue';
|
||||
import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
|
||||
import { DndControlProps } from './types';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const AGGREGATED_DECK_GL_CHART_TYPES = [
|
||||
'deck_screengrid',
|
||||
@@ -326,7 +327,10 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
|
||||
typeof item === 'object' &&
|
||||
'error_text' in item &&
|
||||
item.error_text)
|
||||
? t('This metric might be incompatible with current dataset')
|
||||
? t(
|
||||
'This metric might be incompatible with current %s',
|
||||
datasetLabelLower(),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,6 +29,7 @@ import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/t
|
||||
import { DndItemType } from 'src/explore/components/DndItemType';
|
||||
import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
|
||||
import { DndControlProps } from './types';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
export type DndColumnSelectProps = DndControlProps<QueryFormColumn> & {
|
||||
options: ColumnMeta[];
|
||||
@@ -103,7 +104,10 @@ function DndColumnSelect(props: DndColumnSelectProps) {
|
||||
optionSelector.values.map((column, idx) => {
|
||||
const datasourceWarningMessage =
|
||||
isAdhocColumn(column) && column.datasourceWarning
|
||||
? t('This column might be incompatible with current dataset')
|
||||
? t(
|
||||
'This column might be incompatible with current %s',
|
||||
datasetLabelLower(),
|
||||
)
|
||||
: undefined;
|
||||
const withCaret = isAdhocColumn(column) || !column.error_text;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { DndItemType } from 'src/explore/components/DndItemType';
|
||||
import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
|
||||
import { savedMetricType } from 'src/explore/components/controls/MetricControl/types';
|
||||
import { AGGREGATES } from 'src/explore/constants';
|
||||
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric];
|
||||
@@ -77,7 +78,10 @@ const coerceMetrics = (
|
||||
) {
|
||||
return {
|
||||
metric_name: metric,
|
||||
error_text: t('This metric might be incompatible with current dataset'),
|
||||
error_text: t(
|
||||
'This metric might be incompatible with current %s',
|
||||
datasetLabelLower(),
|
||||
),
|
||||
uuid: nanoid(),
|
||||
};
|
||||
}
|
||||
@@ -296,7 +300,10 @@ const DndMetricSelect = (props: any) => {
|
||||
multi={multi}
|
||||
datasourceWarningMessage={
|
||||
option instanceof AdhocMetric && option.datasourceWarning
|
||||
? t('This metric might be incompatible with current dataset')
|
||||
? t(
|
||||
'This metric might be incompatible with current %s',
|
||||
datasetLabelLower(),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
validateNonEmpty,
|
||||
} from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { datasetLabel } from 'src/utils/semanticLayerLabels';
|
||||
import { formatSelectOptions } from 'src/explore/exploreUtils';
|
||||
import { TIME_FILTER_LABELS } from './constants';
|
||||
import { StyledColumnOption } from './components/optionRenderers';
|
||||
@@ -214,7 +215,7 @@ export const controls = {
|
||||
|
||||
datasource: {
|
||||
type: 'DatasourceControl',
|
||||
label: t('Dataset'),
|
||||
label: datasetLabel(),
|
||||
default: null,
|
||||
description: null,
|
||||
mapStateToProps: ({ datasource }: ControlState) => ({
|
||||
|
||||
@@ -71,6 +71,8 @@ export type OptionSortType = Partial<
|
||||
|
||||
export type Datasource = Dataset & {
|
||||
database?: DatabaseObject;
|
||||
/** The parent resource that owns this datasource (database or semantic layer). */
|
||||
parent?: { name: string };
|
||||
datasource?: string;
|
||||
catalog?: string | null;
|
||||
schema?: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
MenuObjectProps,
|
||||
MenuData,
|
||||
} from 'src/types/bootstrapTypes';
|
||||
import { datasetsLabel } from 'src/utils/semanticLayerLabels';
|
||||
import RightMenu from './RightMenu';
|
||||
import { NAVBAR_MENU_POPUP_OFFSET } from './commonMenuData';
|
||||
|
||||
@@ -221,7 +222,7 @@ export function Menu({
|
||||
setActiveTabs(['Charts']);
|
||||
break;
|
||||
case path.startsWith(Paths.Datasets):
|
||||
setActiveTabs(['Datasets']);
|
||||
setActiveTabs([datasetsLabel()]);
|
||||
break;
|
||||
case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries):
|
||||
setActiveTabs(['SQL']);
|
||||
@@ -399,6 +400,12 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
|
||||
Manage: true,
|
||||
};
|
||||
|
||||
// Remap labels that depend on feature flags so they stay in sync with
|
||||
// the active-tab key used in the Menu component above.
|
||||
const labelOverrides: Record<string, () => string> = {
|
||||
Datasets: datasetsLabel,
|
||||
};
|
||||
|
||||
// Cycle through menu.menu to build out cleanedMenu and settings
|
||||
const cleanedMenu: MenuObjectProps[] = [];
|
||||
const settings: MenuObjectProps[] = [];
|
||||
@@ -410,6 +417,10 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
|
||||
const children: (MenuObjectProps | string)[] = [];
|
||||
const newItem = {
|
||||
...item,
|
||||
// Apply any label override for this item (keyed by FAB internal name).
|
||||
...(item.name && labelOverrides[item.name]
|
||||
? { label: labelOverrides[item.name]() }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Filter childs
|
||||
|
||||
@@ -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,403 @@
|
||||
/**
|
||||
* 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 { styled } from '@apache-superset/core/theme';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { Input } from 'antd';
|
||||
import { Select } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { JsonForms } from '@jsonforms/react';
|
||||
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
|
||||
import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
|
||||
import type { ErrorObject } from 'ajv';
|
||||
import {
|
||||
StandardModal,
|
||||
ModalFormField,
|
||||
MODAL_STANDARD_WIDTH,
|
||||
MODAL_MEDIUM_WIDTH,
|
||||
} from 'src/components/Modal';
|
||||
import {
|
||||
renderers,
|
||||
sanitizeSchema,
|
||||
buildUiSchema,
|
||||
getDynamicDependencies,
|
||||
areDependenciesSatisfied,
|
||||
serializeDependencyValues,
|
||||
SCHEMA_REFRESH_DEBOUNCE_MS,
|
||||
} from './jsonFormsHelpers';
|
||||
|
||||
type Step = 'type' | 'config';
|
||||
type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow';
|
||||
|
||||
const ModalContent = styled.div`
|
||||
padding: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const BackLink = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.colorPrimary};
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: ${({ theme }) => theme.fontSize}px;
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.sizeUnit}px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
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 (isInitialFetch) setStep('config');
|
||||
} catch {
|
||||
if (isInitialFetch) {
|
||||
addDangerToast(
|
||||
t('An error occurred while fetching 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 {
|
||||
addDangerToast(
|
||||
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 {
|
||||
addDangerToast(
|
||||
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);
|
||||
if (
|
||||
validationMode === 'ValidateAndShow' &&
|
||||
errorsRef.current.length === 0
|
||||
) {
|
||||
handleCreate();
|
||||
}
|
||||
maybeRefreshSchema(data);
|
||||
},
|
||||
[validationMode, handleCreate, 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' ? (
|
||||
<ModalContent>
|
||||
<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>
|
||||
</ModalContent>
|
||||
) : (
|
||||
<ModalContent>
|
||||
{!isEditMode && (
|
||||
<BackLink type="button" onClick={handleBack}>
|
||||
<Icons.CaretLeftOutlined iconSize="s" />
|
||||
{t('Back')}
|
||||
</BackLink>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</ModalContent>
|
||||
)}
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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 { useEffect } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Spin } from 'antd';
|
||||
import { withJsonFormsControlProps } from '@jsonforms/react';
|
||||
import type {
|
||||
JsonSchema,
|
||||
UISchemaElement,
|
||||
ControlProps,
|
||||
} from '@jsonforms/core';
|
||||
import {
|
||||
rankWith,
|
||||
and,
|
||||
isStringControl,
|
||||
formatIs,
|
||||
schemaMatches,
|
||||
} from '@jsonforms/core';
|
||||
import {
|
||||
rendererRegistryEntries,
|
||||
TextControl,
|
||||
} from '@great-expectations/jsonforms-antd-renderers';
|
||||
|
||||
export const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* 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 &&
|
||||
!(s as Record<string, unknown>).readOnly,
|
||||
),
|
||||
),
|
||||
renderer: ConstRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderer for read-only fields (e.g. a fixed database that the admin locked).
|
||||
* Renders a disabled input showing the current value. Also ensures the default
|
||||
* value is injected into form data (like ConstControl does for hidden fields).
|
||||
*/
|
||||
function ReadOnlyControl({
|
||||
data,
|
||||
handleChange,
|
||||
path,
|
||||
schema,
|
||||
...rest
|
||||
}: ControlProps) {
|
||||
const defaultValue =
|
||||
(schema as Record<string, unknown>).const ??
|
||||
(schema as Record<string, unknown>).default;
|
||||
useEffect(() => {
|
||||
if (defaultValue !== undefined && data !== defaultValue) {
|
||||
handleChange(path, defaultValue);
|
||||
}
|
||||
}, [defaultValue, data, handleChange, path]);
|
||||
|
||||
return TextControl({
|
||||
...rest,
|
||||
data,
|
||||
handleChange,
|
||||
path,
|
||||
schema,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
const ReadOnlyRenderer = withJsonFormsControlProps(ReadOnlyControl);
|
||||
const readOnlyEntry = {
|
||||
tester: rankWith(
|
||||
11,
|
||||
schemaMatches(
|
||||
s => s !== undefined && (s as Record<string, unknown>).readOnly === true,
|
||||
),
|
||||
),
|
||||
renderer: ReadOnlyRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether all dependency values are filled (non-empty).
|
||||
* Handles nested objects (like auth) by checking they have at least one key.
|
||||
*/
|
||||
export 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: <Spin size="small" /> },
|
||||
},
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
export const renderers = [
|
||||
...rendererRegistryEntries,
|
||||
passwordEntry,
|
||||
constEntry,
|
||||
readOnlyEntry,
|
||||
dynamicFieldEntry,
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 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 { styled } from '@apache-superset/core/theme';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { Spin } from 'antd';
|
||||
import { Select } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { JsonForms } from '@jsonforms/react';
|
||||
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
|
||||
import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
|
||||
import type { ErrorObject } from 'ajv';
|
||||
import {
|
||||
StandardModal,
|
||||
ModalFormField,
|
||||
MODAL_STANDARD_WIDTH,
|
||||
} from 'src/components/Modal';
|
||||
import {
|
||||
renderers,
|
||||
sanitizeSchema,
|
||||
buildUiSchema,
|
||||
getDynamicDependencies,
|
||||
areDependenciesSatisfied,
|
||||
serializeDependencyValues,
|
||||
SCHEMA_REFRESH_DEBOUNCE_MS,
|
||||
} from 'src/features/semanticLayers/jsonFormsHelpers';
|
||||
|
||||
interface SemanticLayerOption {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AvailableView {
|
||||
name: string;
|
||||
already_added: boolean;
|
||||
}
|
||||
|
||||
const ModalContent = styled.div`
|
||||
padding: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const SectionLabel = styled.div`
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
font-size: ${({ theme }) => theme.fontSize}px;
|
||||
margin-top: ${({ theme }) => theme.sizeUnit}px;
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
const VerticalFormFields = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
|
||||
/* The antd renderer's VerticalLayout creates its own <Form> —
|
||||
force flex-column so gap controls spacing between fields */
|
||||
&& form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
}
|
||||
|
||||
/* Reset antd default margins so gap controls all spacing */
|
||||
&& .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Override ant-form-item-horizontal: stack label above control */
|
||||
&& .ant-form-item-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&& .ant-form-item-label {
|
||||
text-align: left;
|
||||
max-width: 100%;
|
||||
flex: none;
|
||||
padding-bottom: ${({ theme }) => theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
&& .ant-form-item-control {
|
||||
max-width: 100%;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
&& .ant-form-item-label > label {
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
font-size: ${({ theme }) => theme.fontSize}px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface AddSemanticViewModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSuccess: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
export default function AddSemanticViewModal({
|
||||
show,
|
||||
onHide,
|
||||
onSuccess,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}: AddSemanticViewModalProps) {
|
||||
// --- Layer ---
|
||||
const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
|
||||
const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [loadingLayers, setLoadingLayers] = useState(false);
|
||||
|
||||
// --- Runtime config ---
|
||||
const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
|
||||
const [runtimeUiSchema, setRuntimeUiSchema] = useState<
|
||||
UISchemaElement | undefined
|
||||
>();
|
||||
const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
|
||||
const [loadingRuntime, setLoadingRuntime] = useState(false);
|
||||
const [refreshingSchema, setRefreshingSchema] = useState(false);
|
||||
const errorsRef = useRef<ErrorObject[]>([]);
|
||||
const dynamicDepsRef = useRef<Record<string, string[]>>({});
|
||||
const lastDepSnapshotRef = useRef('');
|
||||
const schemaTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// --- Views ---
|
||||
const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
|
||||
const [selectedViewNames, setSelectedViewNames] = useState<string[]>([]);
|
||||
const [loadingViews, setLoadingViews] = useState(false);
|
||||
const viewsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastViewsKeyRef = useRef('');
|
||||
|
||||
// --- Misc ---
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fetchGenRef = useRef(0);
|
||||
|
||||
// =========================================================================
|
||||
// Fetch helpers
|
||||
// =========================================================================
|
||||
|
||||
const fetchLayers = async () => {
|
||||
setLoadingLayers(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: '/api/v1/semantic_layer/',
|
||||
});
|
||||
setLayers(
|
||||
(json.result ?? []).map((l: { uuid: string; name: string }) => ({
|
||||
uuid: l.uuid,
|
||||
name: l.name,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
addDangerToast(t('An error occurred while fetching semantic layers'));
|
||||
} finally {
|
||||
setLoadingLayers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchViews = useCallback(
|
||||
async (uuid: string, rData: Record<string, unknown>, gen: number) => {
|
||||
setLoadingViews(true);
|
||||
setAvailableViews([]);
|
||||
setSelectedViewNames([]);
|
||||
try {
|
||||
const { json } = await SupersetClient.post({
|
||||
endpoint: `/api/v1/semantic_layer/${uuid}/views`,
|
||||
jsonPayload: { runtime_data: rData },
|
||||
});
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
setAvailableViews(json.result ?? []);
|
||||
} catch {
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
addDangerToast(t('An error occurred while fetching available views'));
|
||||
} finally {
|
||||
if (gen === fetchGenRef.current) setLoadingViews(false);
|
||||
}
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
|
||||
const schema = sanitizeSchema(rawSchema);
|
||||
setRuntimeSchema(schema);
|
||||
setRuntimeUiSchema(buildUiSchema(schema));
|
||||
dynamicDepsRef.current = getDynamicDependencies(rawSchema);
|
||||
}, []);
|
||||
|
||||
const scheduleFetchViews = useCallback(
|
||||
(uuid: string, data: Record<string, unknown>) => {
|
||||
const key = JSON.stringify(data);
|
||||
if (key === lastViewsKeyRef.current) return;
|
||||
lastViewsKeyRef.current = key;
|
||||
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
|
||||
viewsTimerRef.current = setTimeout(() => {
|
||||
fetchViews(uuid, data, fetchGenRef.current);
|
||||
}, SCHEMA_REFRESH_DEBOUNCE_MS);
|
||||
},
|
||||
[fetchViews],
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Layer change — fetch runtime schema, clear downstream state
|
||||
// =========================================================================
|
||||
|
||||
const handleLayerChange = useCallback(
|
||||
async (uuid: string) => {
|
||||
fetchGenRef.current += 1;
|
||||
const gen = fetchGenRef.current;
|
||||
|
||||
setSelectedLayerUuid(uuid);
|
||||
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
|
||||
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
|
||||
setRuntimeSchema(null);
|
||||
setRuntimeUiSchema(undefined);
|
||||
setRuntimeData({});
|
||||
errorsRef.current = [];
|
||||
dynamicDepsRef.current = {};
|
||||
lastDepSnapshotRef.current = '';
|
||||
setAvailableViews([]);
|
||||
setSelectedViewNames([]);
|
||||
lastViewsKeyRef.current = '';
|
||||
|
||||
setLoadingRuntime(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.post({
|
||||
endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
|
||||
jsonPayload: {},
|
||||
});
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
const schema = json.result;
|
||||
if (
|
||||
!schema?.properties ||
|
||||
Object.keys(schema.properties).length === 0
|
||||
) {
|
||||
// No runtime config needed — fetch views right away
|
||||
fetchViews(uuid, {}, gen);
|
||||
} else {
|
||||
applyRuntimeSchema(schema);
|
||||
}
|
||||
} catch {
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
addDangerToast(
|
||||
t('An error occurred while fetching the runtime schema'),
|
||||
);
|
||||
} finally {
|
||||
if (gen === fetchGenRef.current) setLoadingRuntime(false);
|
||||
}
|
||||
},
|
||||
[applyRuntimeSchema, fetchViews, addDangerToast],
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Runtime form change — refresh dynamic fields or auto-fetch views
|
||||
// =========================================================================
|
||||
|
||||
const handleRuntimeFormChange = useCallback(
|
||||
({
|
||||
data,
|
||||
errors,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
errors?: ErrorObject[];
|
||||
}) => {
|
||||
setRuntimeData(data);
|
||||
errorsRef.current = errors ?? [];
|
||||
|
||||
if (!selectedLayerUuid) return;
|
||||
const gen = fetchGenRef.current;
|
||||
|
||||
// Dynamic deps changed → refresh schema (e.g. database → schema)
|
||||
const dynamicDeps = dynamicDepsRef.current;
|
||||
if (Object.keys(dynamicDeps).length > 0) {
|
||||
const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
|
||||
areDependenciesSatisfied(deps, data),
|
||||
);
|
||||
if (hasSatisfiedDeps) {
|
||||
const snapshot = serializeDependencyValues(dynamicDeps, data);
|
||||
if (snapshot !== lastDepSnapshotRef.current) {
|
||||
lastDepSnapshotRef.current = snapshot;
|
||||
// Config is changing — clear views
|
||||
setAvailableViews([]);
|
||||
setSelectedViewNames([]);
|
||||
lastViewsKeyRef.current = '';
|
||||
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
|
||||
const uuid = selectedLayerUuid;
|
||||
schemaTimerRef.current = setTimeout(async () => {
|
||||
setRefreshingSchema(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.post({
|
||||
endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
|
||||
jsonPayload: { runtime_data: data },
|
||||
});
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
applyRuntimeSchema(json.result);
|
||||
} catch {
|
||||
// Silent fail on refresh — form still works
|
||||
} finally {
|
||||
if (gen === fetchGenRef.current) setRefreshingSchema(false);
|
||||
}
|
||||
}, SCHEMA_REFRESH_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No schema refresh needed — fetch views if form is valid
|
||||
if (errorsRef.current.length === 0) {
|
||||
scheduleFetchViews(selectedLayerUuid, data);
|
||||
}
|
||||
},
|
||||
[selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews],
|
||||
);
|
||||
|
||||
// After a schema refresh settles, JSON Forms re-validates and fires
|
||||
// onChange → handleRuntimeFormChange handles view fetching. As a fallback
|
||||
// (in case onChange doesn't fire), try once refreshingSchema flips false.
|
||||
const prevRefreshingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (prevRefreshingRef.current && !refreshingSchema && selectedLayerUuid) {
|
||||
const timer = setTimeout(() => {
|
||||
if (errorsRef.current.length === 0) {
|
||||
scheduleFetchViews(selectedLayerUuid, runtimeData);
|
||||
}
|
||||
}, 100);
|
||||
prevRefreshingRef.current = false;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
prevRefreshingRef.current = refreshingSchema;
|
||||
return undefined;
|
||||
}, [refreshingSchema, selectedLayerUuid, runtimeData, scheduleFetchViews]);
|
||||
|
||||
// =========================================================================
|
||||
// Modal open / close
|
||||
// =========================================================================
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
fetchLayers();
|
||||
} else {
|
||||
fetchGenRef.current += 1;
|
||||
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
|
||||
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
|
||||
setLayers([]);
|
||||
setSelectedLayerUuid(null);
|
||||
setLoadingLayers(false);
|
||||
setRuntimeSchema(null);
|
||||
setRuntimeUiSchema(undefined);
|
||||
setRuntimeData({});
|
||||
setLoadingRuntime(false);
|
||||
setRefreshingSchema(false);
|
||||
errorsRef.current = [];
|
||||
dynamicDepsRef.current = {};
|
||||
lastDepSnapshotRef.current = '';
|
||||
setAvailableViews([]);
|
||||
setSelectedViewNames([]);
|
||||
setLoadingViews(false);
|
||||
setSaving(false);
|
||||
lastViewsKeyRef.current = '';
|
||||
}
|
||||
}, [show]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// =========================================================================
|
||||
// Save
|
||||
// =========================================================================
|
||||
|
||||
const newViewCount = availableViews.filter(
|
||||
v => selectedViewNames.includes(v.name) && !v.already_added,
|
||||
).length;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedLayerUuid || newViewCount === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const viewsToCreate = availableViews
|
||||
.filter(v => selectedViewNames.includes(v.name) && !v.already_added)
|
||||
.map(v => ({
|
||||
name: v.name,
|
||||
semantic_layer_uuid: selectedLayerUuid,
|
||||
configuration: runtimeData,
|
||||
}));
|
||||
|
||||
await SupersetClient.post({
|
||||
endpoint: '/api/v1/semantic_view/',
|
||||
jsonPayload: { views: viewsToCreate },
|
||||
});
|
||||
|
||||
addSuccessToast(t('%s semantic view(s) added', viewsToCreate.length));
|
||||
onSuccess();
|
||||
onHide();
|
||||
} catch {
|
||||
addDangerToast(t('An error occurred while adding semantic views'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Render
|
||||
// =========================================================================
|
||||
|
||||
const hasRuntimeFields =
|
||||
runtimeSchema?.properties &&
|
||||
Object.keys(runtimeSchema.properties).length > 0;
|
||||
|
||||
const viewsDisabled =
|
||||
loadingViews || (!loadingViews && availableViews.length === 0);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleSave}
|
||||
title={t('Add Semantic View')}
|
||||
icon={<Icons.PlusOutlined />}
|
||||
width={MODAL_STANDARD_WIDTH}
|
||||
saveDisabled={newViewCount === 0 || saving}
|
||||
saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')}
|
||||
saveLoading={saving}
|
||||
>
|
||||
<ModalContent>
|
||||
{/* Semantic Layer */}
|
||||
<ModalFormField label={t('Semantic Layer')}>
|
||||
<Select
|
||||
ariaLabel={t('Semantic layer')}
|
||||
placeholder={t('Select a semantic layer')}
|
||||
loading={loadingLayers}
|
||||
value={selectedLayerUuid}
|
||||
onChange={value => handleLayerChange(value as string)}
|
||||
options={layers.map(l => ({
|
||||
value: l.uuid,
|
||||
label: l.name,
|
||||
}))}
|
||||
getPopupContainer={() => document.body}
|
||||
/>
|
||||
</ModalFormField>
|
||||
|
||||
{/* Loading runtime schema */}
|
||||
{loadingRuntime && (
|
||||
<LoadingContainer>
|
||||
<Spin size="small" />
|
||||
</LoadingContainer>
|
||||
)}
|
||||
|
||||
{/* Source location (runtime config fields) */}
|
||||
{hasRuntimeFields && !loadingRuntime && (
|
||||
<>
|
||||
<SectionLabel>{t('Source location')}</SectionLabel>
|
||||
<VerticalFormFields>
|
||||
<JsonForms
|
||||
schema={runtimeSchema!}
|
||||
uischema={runtimeUiSchema}
|
||||
data={runtimeData}
|
||||
renderers={renderers}
|
||||
cells={cellRegistryEntries}
|
||||
config={{ refreshingSchema, formData: runtimeData }}
|
||||
validationMode="ValidateAndHide"
|
||||
onChange={handleRuntimeFormChange}
|
||||
/>
|
||||
</VerticalFormFields>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Semantic Views — always visible once a layer is selected */}
|
||||
{selectedLayerUuid && !loadingRuntime && (
|
||||
<ModalFormField label={t('Semantic Views')}>
|
||||
<Select
|
||||
ariaLabel={t('Semantic views')}
|
||||
placeholder={t('Select semantic views')}
|
||||
mode="multiple"
|
||||
loading={loadingViews}
|
||||
disabled={viewsDisabled}
|
||||
value={selectedViewNames}
|
||||
onChange={values => setSelectedViewNames(values as string[])}
|
||||
options={availableViews
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(v => ({
|
||||
value: v.name,
|
||||
label: v.already_added
|
||||
? `${v.name} (${t('already added')})`
|
||||
: v.name,
|
||||
disabled: v.already_added,
|
||||
}))}
|
||||
getPopupContainer={() => document.body}
|
||||
/>
|
||||
</ModalFormField>
|
||||
)}
|
||||
</ModalContent>
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
@@ -33,8 +33,8 @@ interface SemanticViewEditModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSave: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast?: (msg: string) => void;
|
||||
addSuccessToast?: (msg: string) => void;
|
||||
semanticView: {
|
||||
id: number;
|
||||
table_name: string;
|
||||
@@ -47,8 +47,8 @@ export default function SemanticViewEditModal({
|
||||
show,
|
||||
onHide,
|
||||
onSave,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
addDangerToast = () => {},
|
||||
addSuccessToast = () => {},
|
||||
semanticView,
|
||||
}: SemanticViewEditModalProps) {
|
||||
const [description, setDescription] = useState<string>('');
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
DatasetSelectLabel,
|
||||
} from 'src/features/datasets/DatasetSelectLabel';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { datasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
export interface ChartCreationProps extends RouteComponentProps {
|
||||
user: UserWithPermissionsAndRoles;
|
||||
@@ -332,18 +333,22 @@ export class ChartCreation extends PureComponent<
|
||||
<h3>{t('Create a new chart')}</h3>
|
||||
<Steps direction="vertical" size="small">
|
||||
<Steps.Step
|
||||
title={<StyledStepTitle>{t('Choose a dataset')}</StyledStepTitle>}
|
||||
title={
|
||||
<StyledStepTitle>
|
||||
{t('Choose a %s', datasetLabelLower())}
|
||||
</StyledStepTitle>
|
||||
}
|
||||
status={this.state.datasource?.value ? 'finish' : 'process'}
|
||||
description={
|
||||
<StyledStepDescription className="dataset">
|
||||
<AsyncSelect
|
||||
autoFocus
|
||||
ariaLabel={t('Dataset')}
|
||||
ariaLabel={datasetLabel()}
|
||||
name="select-datasource"
|
||||
onChange={this.changeDatasource}
|
||||
options={this.loadDatasources}
|
||||
optionFilterProps={['id', 'table_name']}
|
||||
placeholder={t('Choose a dataset')}
|
||||
placeholder={t('Choose a %s', datasetLabelLower())}
|
||||
showSearch
|
||||
value={this.state.datasource}
|
||||
/>
|
||||
@@ -370,7 +375,10 @@ export class ChartCreation extends PureComponent<
|
||||
<div className="footer">
|
||||
{isButtonDisabled && (
|
||||
<span>
|
||||
{t('Please select both a Dataset and a Chart type to proceed')}
|
||||
{t(
|
||||
'Please select both a %s and a Chart type to proceed',
|
||||
datasetLabel(),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -83,6 +83,7 @@ import { findPermission } from 'src/utils/findPermission';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
import { Tag } from 'src/components/Tag';
|
||||
import { datasetLabel } from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const FlexRowContainer = styled.div`
|
||||
align-items: center;
|
||||
@@ -430,7 +431,7 @@ function ChartList(props: ChartListProps) {
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
Header: t('Dataset'),
|
||||
Header: datasetLabel(),
|
||||
accessor: 'datasource_id',
|
||||
disableSortBy: true,
|
||||
size: 'xl',
|
||||
@@ -658,7 +659,7 @@ function ChartList(props: ChartListProps) {
|
||||
}),
|
||||
},
|
||||
{
|
||||
Header: t('Dataset'),
|
||||
Header: datasetLabel(),
|
||||
key: 'dataset',
|
||||
id: 'datasource_id',
|
||||
input: 'select',
|
||||
|
||||
@@ -17,8 +17,13 @@
|
||||
* 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 rison from 'rison';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -33,7 +38,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 +50,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 +63,16 @@ 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 {
|
||||
databaseLabel,
|
||||
databaseLabelLower,
|
||||
databasesLabel,
|
||||
} from 'src/utils/semanticLayerLabels';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
const DatabaseDeleteRelatedExtension = extensionsRegistry.get(
|
||||
@@ -70,6 +84,11 @@ const dbConfigExtraExtension = extensionsRegistry.get(
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type ConnectionItem = DatabaseObject & {
|
||||
source_type?: 'database' | 'semantic_layer';
|
||||
sl_type?: string;
|
||||
};
|
||||
|
||||
interface DatabaseDeleteObject extends DatabaseObject {
|
||||
charts: any;
|
||||
dashboards: any;
|
||||
@@ -108,20 +127,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'),
|
||||
databaseLabelLower(),
|
||||
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 +253,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);
|
||||
@@ -316,22 +428,67 @@ function DatabaseList({
|
||||
const menuData: SubMenuProps = {
|
||||
activeChild: 'Databases',
|
||||
dropDownLinks: filteredDropDown,
|
||||
name: t('Databases'),
|
||||
name: databasesLabel(),
|
||||
};
|
||||
|
||||
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: databaseLabel(),
|
||||
buttonStyle: 'primary',
|
||||
onClick: openDatabaseModal,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const handleDatabaseExport = useCallback(
|
||||
@@ -347,7 +504,9 @@ function DatabaseList({
|
||||
});
|
||||
} catch (error) {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the database'));
|
||||
addDangerToast(
|
||||
t('There was an issue exporting the %s', databaseLabelLower()),
|
||||
);
|
||||
}
|
||||
},
|
||||
[addDangerToast, setPreparingExport],
|
||||
@@ -401,6 +560,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 +589,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 +603,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 } }: any) =>
|
||||
original.source_type === 'semantic_layer' ? (
|
||||
<span>–</span>
|
||||
) : (
|
||||
<BooleanDisplay value={original.allow_run_async} />
|
||||
),
|
||||
size: 'sm',
|
||||
id: 'allow_run_async',
|
||||
},
|
||||
@@ -448,33 +623,36 @@ function DatabaseList({
|
||||
<span>{t('DML')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { allow_dml: allowDML },
|
||||
},
|
||||
}: any) => <BooleanDisplay value={allowDML} />,
|
||||
Cell: ({ row: { original } }: any) =>
|
||||
original.source_type === 'semantic_layer' ? (
|
||||
<span>–</span>
|
||||
) : (
|
||||
<BooleanDisplay value={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 } }: any) =>
|
||||
original.source_type === 'semantic_layer' ? (
|
||||
<span>–</span>
|
||||
) : (
|
||||
<BooleanDisplay value={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 } }: any) =>
|
||||
original.source_type === 'semantic_layer' ? (
|
||||
<span>–</span>
|
||||
) : (
|
||||
<BooleanDisplay value={original.expose_in_sqllab} />
|
||||
),
|
||||
size: 'md',
|
||||
id: 'expose_in_sqllab',
|
||||
},
|
||||
@@ -494,6 +672,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);
|
||||
@@ -564,7 +784,7 @@ function DatabaseList({
|
||||
>
|
||||
<Tooltip
|
||||
id="delete-action-tooltip"
|
||||
title={t('Delete database')}
|
||||
title={t('Delete %s', databaseLabelLower())}
|
||||
placement="bottom"
|
||||
>
|
||||
<Icons.DeleteOutlined iconSize="l" />
|
||||
@@ -579,6 +799,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 +822,8 @@ function DatabaseList({
|
||||
],
|
||||
);
|
||||
|
||||
const filters: ListViewFilters = useMemo(
|
||||
() => [
|
||||
const filters: ListViewFilters = useMemo(() => {
|
||||
const baseFilters: ListViewFilters = [
|
||||
{
|
||||
Header: t('Name'),
|
||||
key: 'search',
|
||||
@@ -605,62 +831,84 @@ 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 %s values: %s',
|
||||
databaseLabelLower(),
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
user,
|
||||
),
|
||||
paginate: true,
|
||||
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return baseFilters;
|
||||
}, [showSemanticLayers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -703,12 +951,54 @@ 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={
|
||||
<>
|
||||
<p>
|
||||
{t('The database')}{' '}
|
||||
{t('The %s', databaseLabelLower())}{' '}
|
||||
<b>{databaseCurrentlyDeleting.database_name}</b>{' '}
|
||||
{t(
|
||||
'is linked to %s charts that appear on %s dashboards and users have %s SQL Lab tabs using this database open. Are you sure you want to continue? Deleting the database will break those objects.',
|
||||
@@ -816,7 +1106,7 @@ function DatabaseList({
|
||||
title={
|
||||
<ModalTitleWithIcon
|
||||
icon={<Icons.DeleteOutlined />}
|
||||
title={t('Delete Database?')}
|
||||
title={t('Delete %s?', databaseLabel())}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,14 @@ import {
|
||||
FeatureFlag,
|
||||
} from '@superset-ui/core';
|
||||
import { styled, useTheme, css } from '@apache-superset/core/theme';
|
||||
import { FunctionComponent, useState, useMemo, useCallback, Key } from 'react';
|
||||
import {
|
||||
FunctionComponent,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
Key,
|
||||
} from 'react';
|
||||
import type { CellProps } from 'react-table';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import rison from 'rison';
|
||||
@@ -38,9 +45,11 @@ import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'
|
||||
import { ColumnObject } from 'src/features/datasets/types';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import {
|
||||
Button,
|
||||
ConfirmStatusChange,
|
||||
CertifiedBadge,
|
||||
DeleteModal,
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
InfoTooltip,
|
||||
DatasetTypeLabel,
|
||||
@@ -77,6 +86,14 @@ import {
|
||||
import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal';
|
||||
import type DatasetType from 'src/types/Dataset';
|
||||
import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
|
||||
import AddSemanticViewModal from 'src/features/semanticViews/AddSemanticViewModal';
|
||||
import {
|
||||
datasetLabel,
|
||||
datasetLabelLower,
|
||||
datasetsLabel,
|
||||
datasetsLabelLower,
|
||||
databaseLabel,
|
||||
} from 'src/utils/semanticLayerLabels';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
@@ -172,7 +189,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
state: { bulkSelectEnabled },
|
||||
hasPerm,
|
||||
toggleBulkSelect,
|
||||
} = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
|
||||
} = useListViewResource<Dataset>(
|
||||
'dataset',
|
||||
datasetLabelLower(),
|
||||
addDangerToast,
|
||||
);
|
||||
|
||||
// Combined endpoint state
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
@@ -180,6 +201,168 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastFetchConfig, setLastFetchConfig] =
|
||||
useState<ListViewFetchDataConfig | null>(null);
|
||||
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]);
|
||||
|
||||
// Track the current type and connection filter values so cascade-clear logic
|
||||
// can inspect them when a different filter changes.
|
||||
const currentTypeFilter = useRef<unknown>(undefined);
|
||||
const currentConnectionFilter = useRef<unknown>(undefined);
|
||||
|
||||
// Ref wired to ListView's filter controls for programmatic per-filter clearing.
|
||||
const filtersRef = useRef<{
|
||||
clearFilters: () => void;
|
||||
clearFilterById: (id: string) => void;
|
||||
}>(null);
|
||||
|
||||
/**
|
||||
* Cascade-clear incompatible filters when one filter changes.
|
||||
*
|
||||
* Rules:
|
||||
* - Selecting a DB connection → clear "Semantic View" type
|
||||
* - Selecting a SL connection → clear "Physical" / "Virtual" type
|
||||
* - Selecting Physical/Virtual type → clear any SL connection
|
||||
* - Selecting Semantic View type → clear any DB connection
|
||||
* - Selecting Source=Database → clear SL connection + Semantic View type
|
||||
* - Selecting Source=Semantic Layer → clear DB connection + Physical/Virtual type
|
||||
*/
|
||||
const cascadeClear = useCallback(
|
||||
(changed: 'source' | 'type' | 'connection', newValue: unknown) => {
|
||||
if (!isFeatureEnabled(FeatureFlag.SemanticLayers)) return;
|
||||
|
||||
const isSlConnection = (v: unknown) =>
|
||||
typeof v === 'string' && v.startsWith('sl:');
|
||||
const isDbConnection = (v: unknown) =>
|
||||
v !== undefined && v !== null && v !== '' && !isSlConnection(v);
|
||||
const isSemanticViewType = (v: unknown) => v === 'semantic_view';
|
||||
const isPhysicalVirtualType = (v: unknown) => v === true || v === false;
|
||||
|
||||
if (changed === 'connection') {
|
||||
if (
|
||||
isSlConnection(newValue) &&
|
||||
isPhysicalVirtualType(currentTypeFilter.current)
|
||||
) {
|
||||
filtersRef.current?.clearFilterById('sql');
|
||||
}
|
||||
if (
|
||||
isDbConnection(newValue) &&
|
||||
isSemanticViewType(currentTypeFilter.current)
|
||||
) {
|
||||
filtersRef.current?.clearFilterById('sql');
|
||||
}
|
||||
}
|
||||
|
||||
if (changed === 'type') {
|
||||
if (
|
||||
isSemanticViewType(newValue) &&
|
||||
isDbConnection(currentConnectionFilter.current)
|
||||
) {
|
||||
filtersRef.current?.clearFilterById('database');
|
||||
}
|
||||
if (
|
||||
isPhysicalVirtualType(newValue) &&
|
||||
isSlConnection(currentConnectionFilter.current)
|
||||
) {
|
||||
filtersRef.current?.clearFilterById('database');
|
||||
}
|
||||
}
|
||||
|
||||
if (changed === 'source') {
|
||||
const src = newValue as string;
|
||||
if (src === 'database') {
|
||||
if (isSemanticViewType(currentTypeFilter.current)) {
|
||||
filtersRef.current?.clearFilterById('sql');
|
||||
}
|
||||
if (isSlConnection(currentConnectionFilter.current)) {
|
||||
filtersRef.current?.clearFilterById('database');
|
||||
}
|
||||
}
|
||||
if (src === 'semantic_layer') {
|
||||
if (isPhysicalVirtualType(currentTypeFilter.current)) {
|
||||
filtersRef.current?.clearFilterById('sql');
|
||||
}
|
||||
if (isDbConnection(currentConnectionFilter.current)) {
|
||||
filtersRef.current?.clearFilterById('database');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetches "Data connection" filter options — a combined list of databases
|
||||
* and semantic layers.
|
||||
*
|
||||
* Semantic layer values are prefixed with "sl:" so that fetchData can tell
|
||||
* them apart from integer database IDs and route to the correct API filter.
|
||||
*/
|
||||
const fetchConnectionOptions = useCallback(
|
||||
async (filterValue = '', page: number, pageSize: number) => {
|
||||
const showDatabases = currentSourceFilter !== 'semantic_layer';
|
||||
const showSemanticLayers =
|
||||
isFeatureEnabled(FeatureFlag.SemanticLayers) &&
|
||||
currentSourceFilter !== 'database';
|
||||
|
||||
const [dbResult, slResult] = await Promise.all([
|
||||
showDatabases
|
||||
? createFetchRelated(
|
||||
'dataset',
|
||||
'database',
|
||||
createErrorHandler(errMsg =>
|
||||
t(
|
||||
'An error occurred while fetching %s: %s',
|
||||
datasetsLabelLower(),
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
)(filterValue, page, pageSize)
|
||||
: Promise.resolve({ data: [], totalCount: 0 }),
|
||||
showSemanticLayers
|
||||
? SupersetClient.get({
|
||||
endpoint: `/api/v1/semantic_layer/?q=${rison.encode_uri({
|
||||
...(filterValue
|
||||
? {
|
||||
filters: [{ col: 'name', opr: 'ct', value: filterValue }],
|
||||
}
|
||||
: {}),
|
||||
page: 0,
|
||||
page_size: 100,
|
||||
})}`,
|
||||
})
|
||||
.then(({ json = {} }) => ({
|
||||
data: (json?.result ?? []).map(
|
||||
(layer: { uuid: string; name: string }) => ({
|
||||
label: layer.name,
|
||||
// "sl:" prefix distinguishes semantic layers from DB integer IDs
|
||||
value: `sl:${layer.uuid}`,
|
||||
}),
|
||||
),
|
||||
totalCount: json?.count ?? 0,
|
||||
}))
|
||||
.catch(() => ({ data: [], totalCount: 0 }))
|
||||
: Promise.resolve({ data: [], totalCount: 0 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
// Semantic layers first, then databases
|
||||
data: [...slResult.data, ...dbResult.data],
|
||||
totalCount: slResult.totalCount + dbResult.totalCount,
|
||||
};
|
||||
},
|
||||
[currentSourceFilter],
|
||||
);
|
||||
|
||||
const fetchData = useCallback(
|
||||
(config: ListViewFetchDataConfig) => {
|
||||
@@ -187,11 +370,12 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
setLoading(true);
|
||||
const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
|
||||
|
||||
// Separate source_type filter from other filters
|
||||
// Separate source_type and database/connection filters for special handling
|
||||
const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
|
||||
const databaseFilter = filterValues.find(f => f.id === 'database');
|
||||
|
||||
const otherFilters = filterValues
|
||||
.filter(f => f.id !== 'source_type')
|
||||
.filter(f => f.id !== 'source_type' && f.id !== 'database')
|
||||
.filter(
|
||||
({ value }) => value !== '' && value !== null && value !== undefined,
|
||||
)
|
||||
@@ -204,18 +388,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
: 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,
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
@@ -225,6 +409,30 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
...(otherFilters.length ? { filters: otherFilters } : {}),
|
||||
});
|
||||
|
||||
// Translate the "Data connection" filter: values prefixed with "sl:" are
|
||||
// semantic layer UUIDs; plain values are database IDs.
|
||||
if (databaseFilter?.value !== undefined && databaseFilter.value !== '') {
|
||||
const raw =
|
||||
databaseFilter.value &&
|
||||
typeof databaseFilter.value === 'object' &&
|
||||
'value' in databaseFilter.value
|
||||
? (databaseFilter.value as { value: unknown }).value
|
||||
: databaseFilter.value;
|
||||
if (typeof raw === 'string' && raw.startsWith('sl:')) {
|
||||
otherFilters.push({
|
||||
col: 'semantic_layer_uuid',
|
||||
opr: 'eq',
|
||||
value: raw.slice(3),
|
||||
});
|
||||
} else if (raw !== null && raw !== undefined && raw !== '') {
|
||||
otherFilters.push({
|
||||
col: 'database',
|
||||
opr: databaseFilter.operator,
|
||||
value: raw as string | number,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/datasource/?q=${queryParams}`,
|
||||
})
|
||||
@@ -233,7 +441,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
setDatasetCount(json.count);
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(t('An error occurred while fetching datasets'));
|
||||
addDangerToast(
|
||||
t('An error occurred while fetching %s', datasetsLabelLower()),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
@@ -267,6 +477,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
null,
|
||||
);
|
||||
|
||||
const [svCurrentlyDeleting, setSvCurrentlyDeleting] =
|
||||
useState<Dataset | null>(null);
|
||||
|
||||
const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
|
||||
useState(false);
|
||||
const [importingDataset, showImportModal] = useState<boolean>(false);
|
||||
const [passwordFields, setPasswordFields] = useState<string[]>([]);
|
||||
const [preparingExport, setPreparingExport] = useState<boolean>(false);
|
||||
@@ -289,20 +504,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
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);
|
||||
};
|
||||
@@ -314,7 +515,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
const handleDatasetImport = () => {
|
||||
showImportModal(false);
|
||||
refreshData();
|
||||
addSuccessToast(t('Dataset imported'));
|
||||
addSuccessToast(t('%s imported', datasetLabel()));
|
||||
};
|
||||
|
||||
const canEdit = hasPerm('can_write');
|
||||
@@ -352,7 +553,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(
|
||||
t('An error occurred while fetching dataset related data'),
|
||||
t(
|
||||
'An error occurred while fetching %s related data',
|
||||
datasetLabelLower(),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
@@ -396,12 +600,40 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
});
|
||||
} catch {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected datasets'));
|
||||
addDangerToast(
|
||||
t(
|
||||
'There was an issue exporting the selected %s',
|
||||
datasetsLabelLower(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[addDangerToast, setPreparingExport],
|
||||
);
|
||||
|
||||
const handleSemanticViewDelete = (sv: Dataset) => {
|
||||
setSvCurrentlyDeleting(sv);
|
||||
};
|
||||
|
||||
const handleSemanticViewDeleteConfirm = () => {
|
||||
if (!svCurrentlyDeleting) return;
|
||||
const { id, table_name: tableName } = svCurrentlyDeleting;
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/semantic_view/${id}`,
|
||||
}).then(
|
||||
() => {
|
||||
setSvCurrentlyDeleting(null);
|
||||
refreshData();
|
||||
addSuccessToast(t('Deleted: %s', tableName));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('There was an issue deleting %s: %s', tableName, errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -486,7 +718,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
original: { database },
|
||||
},
|
||||
}: CellProps<Dataset>) => database?.database_name || '-',
|
||||
Header: t('Database'),
|
||||
Header: databaseLabel(),
|
||||
accessor: 'database.database_name',
|
||||
size: 'xl',
|
||||
id: 'database.database_name',
|
||||
@@ -551,25 +783,43 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
Cell: ({ row: { original } }: CellProps<Dataset>) => {
|
||||
const isSemanticView = original.kind === 'semantic_view';
|
||||
|
||||
// Semantic view: only show edit button
|
||||
// Semantic view: show edit and delete buttons
|
||||
if (isSemanticView) {
|
||||
if (!canEdit) return null;
|
||||
if (!canEdit && !canDelete) 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)}
|
||||
{canDelete && (
|
||||
<Tooltip
|
||||
id="delete-action-tooltip"
|
||||
title={t('Delete')}
|
||||
placement="bottom"
|
||||
>
|
||||
<Icons.EditOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={() => handleSemanticViewDelete(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={() => setSvCurrentlyEditing(original)}
|
||||
>
|
||||
<Icons.EditOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
@@ -706,6 +956,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
{ label: t('Database'), value: 'database' },
|
||||
{ label: t('Semantic Layer'), value: 'semantic_layer' },
|
||||
],
|
||||
onFilterUpdate: (option: any) => {
|
||||
cascadeClear('source', option?.value);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -736,6 +989,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
? [{ label: t('Semantic View'), value: 'semantic_view' }]
|
||||
: []),
|
||||
],
|
||||
onFilterUpdate: (option: any) => {
|
||||
currentTypeFilter.current = option?.value;
|
||||
cascadeClear('type', option?.value);
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
@@ -753,21 +1010,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
},
|
||||
]),
|
||||
{
|
||||
Header: t('Database'),
|
||||
Header: databaseLabel(),
|
||||
key: 'database',
|
||||
id: 'database',
|
||||
input: 'select',
|
||||
input: 'select' as const,
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: createFetchRelated(
|
||||
'dataset',
|
||||
'database',
|
||||
createErrorHandler(errMsg =>
|
||||
t('An error occurred while fetching datasets: %s', errMsg),
|
||||
),
|
||||
),
|
||||
fetchSelects: fetchConnectionOptions,
|
||||
paginate: true,
|
||||
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
|
||||
onFilterUpdate: (option: any) => {
|
||||
currentConnectionFilter.current = option?.value;
|
||||
cascadeClear('connection', option?.value);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: t('Schema'),
|
||||
@@ -797,7 +1052,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
'dataset',
|
||||
createErrorHandler(errMsg =>
|
||||
t(
|
||||
'An error occurred while fetching dataset owner values: %s',
|
||||
'An error occurred while fetching %s owner values: %s',
|
||||
datasetLabelLower(),
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
@@ -832,7 +1088,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
'changed_by',
|
||||
createErrorHandler(errMsg =>
|
||||
t(
|
||||
'An error occurred while fetching dataset datasource values: %s',
|
||||
'An error occurred while fetching %s values: %s',
|
||||
datasetLabelLower(),
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
@@ -847,7 +1104,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
|
||||
const menuData: SubMenuProps = {
|
||||
activeChild: 'Datasets',
|
||||
name: t('Datasets'),
|
||||
name: datasetsLabel(),
|
||||
};
|
||||
|
||||
const buttonArr: Array<ButtonProps> = [];
|
||||
@@ -857,7 +1114,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
name: (
|
||||
<Tooltip
|
||||
id="import-tooltip"
|
||||
title={t('Import datasets')}
|
||||
title={t('Import %s', datasetsLabelLower())}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Icons.DownloadOutlined
|
||||
@@ -881,14 +1138,58 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
}
|
||||
|
||||
if (canCreate) {
|
||||
buttonArr.push({
|
||||
icon: <Icons.PlusOutlined iconSize="m" />,
|
||||
name: t('Dataset'),
|
||||
onClick: () => {
|
||||
history.push('/dataset/add/');
|
||||
},
|
||||
buttonStyle: 'primary',
|
||||
});
|
||||
if (isFeatureEnabled(FeatureFlag.SemanticLayers)) {
|
||||
buttonArr.push({
|
||||
name: t('New'),
|
||||
buttonStyle: 'primary',
|
||||
component: (
|
||||
<Dropdown
|
||||
css={css`
|
||||
margin-left: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'dataset',
|
||||
label: t('Dataset'),
|
||||
onClick: () => history.push('/dataset/add/'),
|
||||
},
|
||||
{
|
||||
key: 'semantic-view',
|
||||
label: t('Semantic View'),
|
||||
onClick: () => setShowAddSemanticViewModal(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 {
|
||||
buttonArr.push({
|
||||
icon: <Icons.PlusOutlined iconSize="m" />,
|
||||
name: datasetLabel(),
|
||||
onClick: () => {
|
||||
history.push('/dataset/add/');
|
||||
},
|
||||
buttonStyle: 'primary',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
menuData.buttons = buttonArr;
|
||||
@@ -923,26 +1224,54 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
};
|
||||
|
||||
const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/dataset/?q=${rison.encode(
|
||||
datasetsToDelete.map(({ id }) => id),
|
||||
)}`,
|
||||
}).then(
|
||||
({ json = {} }) => {
|
||||
refreshData();
|
||||
addSuccessToast(json.message);
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('There was an issue deleting the selected datasets: %s', errMsg),
|
||||
),
|
||||
),
|
||||
const datasets = datasetsToDelete.filter(
|
||||
d => d.source_type !== 'semantic_layer',
|
||||
);
|
||||
const semanticViews = datasetsToDelete.filter(
|
||||
d => d.source_type === 'semantic_layer',
|
||||
);
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
if (datasets.length) {
|
||||
promises.push(
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/dataset/?q=${rison.encode(
|
||||
datasets.map(({ id }) => id),
|
||||
)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (semanticViews.length) {
|
||||
promises.push(
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/semantic_view/?q=${rison.encode(
|
||||
semanticViews.map(({ id }) => id),
|
||||
)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Promise.allSettled(promises).then(results => {
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
// Always refresh so the list reflects whatever actually got deleted.
|
||||
refreshData();
|
||||
if (failures.length === 0) {
|
||||
addSuccessToast(t('Deleted %s item(s)', datasetsToDelete.length));
|
||||
} else {
|
||||
addDangerToast(
|
||||
t('There was an issue deleting the selected %s', datasetsLabelLower()),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDatasetDuplicate = (newDatasetName: string) => {
|
||||
if (datasetCurrentlyDuplicating === null) {
|
||||
addDangerToast(t('There was an issue duplicating the dataset.'));
|
||||
addDangerToast(
|
||||
t('There was an issue duplicating the %s.', datasetLabelLower()),
|
||||
);
|
||||
}
|
||||
|
||||
SupersetClient.post({
|
||||
@@ -958,7 +1287,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('There was an issue duplicating the selected datasets: %s', errMsg),
|
||||
t(
|
||||
'There was an issue duplicating the selected %s: %s',
|
||||
datasetsLabelLower(),
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -972,7 +1305,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
{t('The dataset')}
|
||||
{t('The %s', datasetLabelLower())}
|
||||
<b> {datasetCurrentlyDeleting.table_name} </b>
|
||||
{t(
|
||||
'is linked to %s charts that appear on %s dashboards. Are you sure you want to continue? Deleting the dataset will break those objects.',
|
||||
@@ -1078,7 +1411,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
}}
|
||||
onHide={closeDatasetDeleteModal}
|
||||
open
|
||||
title={t('Delete Dataset?')}
|
||||
title={t('Delete %s?', datasetLabel())}
|
||||
/>
|
||||
)}
|
||||
{svCurrentlyDeleting && (
|
||||
<DeleteModal
|
||||
description={t(
|
||||
'Are you sure you want to delete %s?',
|
||||
svCurrentlyDeleting.table_name,
|
||||
)}
|
||||
onConfirm={handleSemanticViewDeleteConfirm}
|
||||
onHide={() => setSvCurrentlyDeleting(null)}
|
||||
open
|
||||
title={t('Delete Semantic View?')}
|
||||
/>
|
||||
)}
|
||||
{datasetCurrentlyEditing && (
|
||||
@@ -1102,10 +1447,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
addSuccessToast={addSuccessToast}
|
||||
semanticView={svCurrentlyEditing}
|
||||
/>
|
||||
<AddSemanticViewModal
|
||||
show={showAddSemanticViewModal}
|
||||
onHide={() => setShowAddSemanticViewModal(false)}
|
||||
onSuccess={refreshData}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
/>
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t(
|
||||
'Are you sure you want to delete the selected datasets?',
|
||||
'Are you sure you want to delete the selected %s?',
|
||||
datasetsLabelLower(),
|
||||
)}
|
||||
onConfirm={handleBulkDatasetDelete}
|
||||
>
|
||||
@@ -1136,6 +1489,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
pageSize={PAGE_SIZE}
|
||||
fetchData={fetchData}
|
||||
filters={filterTypes}
|
||||
filtersRef={filtersRef}
|
||||
loading={loading}
|
||||
initialSort={initialSort}
|
||||
bulkActions={bulkActions}
|
||||
@@ -1188,7 +1542,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
|
||||
<ImportModelsModal
|
||||
resourceName="dataset"
|
||||
resourceLabel={t('dataset')}
|
||||
resourceLabel={datasetLabelLower()}
|
||||
passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE}
|
||||
confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE}
|
||||
addDangerToast={addDangerToast}
|
||||
|
||||
63
superset-frontend/src/utils/semanticLayerLabels.ts
Normal file
63
superset-frontend/src/utils/semanticLayerLabels.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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 { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
/**
|
||||
* When the SEMANTIC_LAYERS feature flag is enabled the UI broadens
|
||||
* "dataset" → "datasource" and "database" → "data connection" so
|
||||
* that semantic views and semantic layers feel like first-class
|
||||
* citizens alongside traditional datasets and database connections.
|
||||
*/
|
||||
function sl<T>(legacy: T, semantic: T): T {
|
||||
return isFeatureEnabled(FeatureFlag.SemanticLayers) ? semantic : legacy;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "dataset" family
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Capitalized singular: "Dataset" / "Datasource" */
|
||||
export const datasetLabel = () => sl(t('Dataset'), t('Datasource'));
|
||||
|
||||
/** Lower-case singular: "dataset" / "datasource" */
|
||||
export const datasetLabelLower = () => sl(t('dataset'), t('datasource'));
|
||||
|
||||
/** Capitalized plural: "Datasets" / "Datasources" */
|
||||
export const datasetsLabel = () => sl(t('Datasets'), t('Datasources'));
|
||||
|
||||
/** Lower-case plural: "datasets" / "datasources" */
|
||||
export const datasetsLabelLower = () => sl(t('datasets'), t('datasources'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "database" family
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Capitalized singular: "Database" / "Data connection" */
|
||||
export const databaseLabel = () => sl(t('Database'), t('Data connection'));
|
||||
|
||||
/** Lower-case singular: "database" / "data connection" */
|
||||
export const databaseLabelLower = () => sl(t('database'), t('data connection'));
|
||||
|
||||
/** Capitalized plural: "Databases" / "Data connections" */
|
||||
export const databasesLabel = () => sl(t('Databases'), t('Data connections'));
|
||||
|
||||
/** Lower-case plural: "databases" / "data connections" */
|
||||
export const databasesLabelLower = () =>
|
||||
sl(t('databases'), t('data connections'));
|
||||
@@ -62,11 +62,32 @@ class GetCombinedDatasourceListCommand(BaseCommand):
|
||||
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,
|
||||
name_filter,
|
||||
sql_filter,
|
||||
type_filter,
|
||||
database_id,
|
||||
semantic_layer_uuid,
|
||||
) = self._parse_filters(filters)
|
||||
|
||||
# A connection filter implicitly narrows the source type: selecting a
|
||||
# database ID means "show only datasets", and selecting a semantic layer
|
||||
# UUID means "show only semantic views". Only apply the implicit
|
||||
# narrowing when the user hasn't already set an explicit source_type.
|
||||
if source_type == "all":
|
||||
if database_id is not None:
|
||||
source_type = "database"
|
||||
elif semantic_layer_uuid is not None:
|
||||
source_type = "semantic_layer"
|
||||
|
||||
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 == "empty":
|
||||
return {"count": 0, "result": []}
|
||||
|
||||
ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter, database_id)
|
||||
sv_q = DatasourceDAO.build_semantic_view_query(name_filter, semantic_layer_uuid)
|
||||
|
||||
if source_type == "database":
|
||||
combined = ds_q.subquery()
|
||||
@@ -108,15 +129,30 @@ class GetCombinedDatasourceListCommand(BaseCommand):
|
||||
sql_filter: bool | None,
|
||||
type_filter: str | None,
|
||||
) -> str:
|
||||
"""Narrow source_type based on access flags, sql filter, and type filter."""
|
||||
"""Narrow source_type based on access flags, sql filter, and type filter.
|
||||
|
||||
Returns one of: "database", "semantic_layer", "all", or "empty".
|
||||
"empty" signals that the caller should short-circuit and return no results
|
||||
(used when the user explicitly requests semantic views but lacks access).
|
||||
"""
|
||||
if not self._can_read_semantic_views:
|
||||
# If the user explicitly asked for semantic views but cannot read them,
|
||||
# return "empty" so the caller yields zero results rather than silently
|
||||
# falling back to the full dataset list.
|
||||
if source_type == "semantic_layer" or type_filter == "semantic_view":
|
||||
return "empty"
|
||||
return "database"
|
||||
if not self._can_read_datasets:
|
||||
return "semantic_layer"
|
||||
# An explicit source_type selection ("database" or "semantic_layer") always
|
||||
# wins. This prevents e.g. Type="Semantic View" from overriding an explicit
|
||||
# Source="Database" filter and showing inconsistent results.
|
||||
if source_type in ("database", "semantic_layer"):
|
||||
return source_type
|
||||
# sql_filter (physical/virtual toggle) only applies to datasets
|
||||
if sql_filter is not None:
|
||||
return "database"
|
||||
# Explicit semantic-view type filter
|
||||
# Explicit semantic-view type filter (only reached when source_type="all")
|
||||
if type_filter == "semantic_view":
|
||||
return "semantic_layer"
|
||||
return source_type
|
||||
@@ -124,20 +160,24 @@ class GetCombinedDatasourceListCommand(BaseCommand):
|
||||
@staticmethod
|
||||
def _parse_filters(
|
||||
filters: list[dict[str, Any]],
|
||||
) -> tuple[str, str | None, bool | None, str | None]:
|
||||
) -> tuple[str, str | None, bool | None, str | None, int | 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" | "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
|
||||
database_id: filter datasets to a specific database ID
|
||||
semantic_layer_uuid: filter semantic views to a specific semantic layer UUID
|
||||
"""
|
||||
source_type = "all"
|
||||
name_filter: str | None = None
|
||||
sql_filter: bool | None = None
|
||||
type_filter: str | None = None
|
||||
database_id: int | None = None
|
||||
semantic_layer_uuid: str | None = None
|
||||
|
||||
for f in filters:
|
||||
col = f.get("col")
|
||||
@@ -153,5 +193,19 @@ class GetCombinedDatasourceListCommand(BaseCommand):
|
||||
type_filter = "semantic_view"
|
||||
elif opr == "dataset_is_null_or_empty" and isinstance(value, bool):
|
||||
sql_filter = value
|
||||
elif col == "database" and value is not None:
|
||||
try:
|
||||
database_id = int(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif col == "semantic_layer_uuid" and value is not None:
|
||||
semantic_layer_uuid = str(value)
|
||||
|
||||
return source_type, name_filter, sql_filter, type_filter
|
||||
return (
|
||||
source_type,
|
||||
name_filter,
|
||||
sql_filter,
|
||||
type_filter,
|
||||
database_id,
|
||||
semantic_layer_uuid,
|
||||
)
|
||||
|
||||
104
superset/commands/semantic_layer/create.py
Normal file
104
superset/commands/semantic_layer/create.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# 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,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewCreateFailedError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
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"])
|
||||
|
||||
|
||||
class CreateSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
|
||||
@transaction(
|
||||
on_error=partial(
|
||||
on_error,
|
||||
catches=(SQLAlchemyError, ValueError),
|
||||
reraise=SemanticViewCreateFailedError,
|
||||
)
|
||||
)
|
||||
def run(self) -> Model:
|
||||
self.validate()
|
||||
if isinstance(self._properties.get("configuration"), dict):
|
||||
self._properties["configuration"] = json.dumps(
|
||||
self._properties["configuration"]
|
||||
)
|
||||
return SemanticViewDAO.create(attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
|
||||
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
name: str = self._properties.get("name", "")
|
||||
configuration: dict[str, Any] = self._properties.get("configuration") or {}
|
||||
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
|
||||
raise ValueError(
|
||||
f"Semantic view '{name}' already exists for this layer"
|
||||
" and configuration"
|
||||
)
|
||||
115
superset/commands/semantic_layer/delete.py
Normal file
115
superset/commands/semantic_layer/delete.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# 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 import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
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()
|
||||
|
||||
|
||||
class DeleteSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, pk: int):
|
||||
self._pk = pk
|
||||
self._model: SemanticView | None = None
|
||||
|
||||
@transaction(
|
||||
on_error=partial(
|
||||
on_error,
|
||||
catches=(SQLAlchemyError,),
|
||||
reraise=SemanticViewDeleteFailedError,
|
||||
)
|
||||
)
|
||||
def run(self) -> None:
|
||||
self.validate()
|
||||
assert self._model
|
||||
SemanticViewDAO.delete([self._model])
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
try:
|
||||
security_manager.raise_for_ownership(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
|
||||
|
||||
class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, model_ids: list[int]):
|
||||
self._model_ids = model_ids
|
||||
self._models: list[SemanticView] = []
|
||||
|
||||
@transaction(
|
||||
on_error=partial(
|
||||
on_error,
|
||||
catches=(SQLAlchemyError,),
|
||||
reraise=SemanticViewDeleteFailedError,
|
||||
)
|
||||
)
|
||||
def run(self) -> None:
|
||||
self.validate()
|
||||
SemanticViewDAO.delete(self._models)
|
||||
|
||||
def validate(self) -> None:
|
||||
self._models = SemanticViewDAO.find_by_ids(self._model_ids, id_column="id")
|
||||
if len(self._models) != len(self._model_ids):
|
||||
raise SemanticViewNotFoundError()
|
||||
for model in self._models:
|
||||
try:
|
||||
security_manager.raise_for_ownership(model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
@@ -19,6 +19,8 @@ from flask_babel import lazy_gettext as _
|
||||
from superset.commands.exceptions import (
|
||||
CommandException,
|
||||
CommandInvalidError,
|
||||
CreateFailedError,
|
||||
DeleteFailedError,
|
||||
ForbiddenError,
|
||||
UpdateFailedError,
|
||||
)
|
||||
@@ -39,3 +41,36 @@ class SemanticViewInvalidError(CommandInvalidError):
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
class SemanticViewCreateFailedError(CreateFailedError):
|
||||
message = _("Semantic view could not be created.")
|
||||
|
||||
|
||||
class SemanticViewDeleteFailedError(DeleteFailedError):
|
||||
message = _("Semantic view could not be deleted.")
|
||||
|
||||
@@ -26,13 +26,17 @@ 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 SemanticViewDAO
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticView
|
||||
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
|
||||
|
||||
@@ -83,3 +87,40 @@ class UpdateSemanticViewCommand(BaseCommand):
|
||||
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)
|
||||
|
||||
@@ -449,6 +449,7 @@ class BaseDatasource(
|
||||
"column_formats": self.column_formats,
|
||||
"description": self.description,
|
||||
"database": self.database.data, # pylint: disable=no-member
|
||||
"parent": {"name": self.database.data["name"]}, # pylint: disable=no-member
|
||||
"default_endpoint": self.default_endpoint,
|
||||
"filter_select": self.filter_select_enabled, # TODO deprecate
|
||||
"filter_select_enabled": self.filter_select_enabled,
|
||||
|
||||
@@ -95,6 +95,7 @@ class DatasourceDAO(BaseDAO[Datasource]):
|
||||
def build_dataset_query(
|
||||
name_filter: str | None,
|
||||
sql_filter: bool | None,
|
||||
database_id: int | None = None,
|
||||
) -> Select:
|
||||
"""Build a SELECT for datasets, applying access and content filters."""
|
||||
ds_q = select(
|
||||
@@ -121,11 +122,17 @@ class DatasourceDAO(BaseDAO[Datasource]):
|
||||
else:
|
||||
ds_q = ds_q.where(and_(SqlaTable.sql.isnot(None), SqlaTable.sql != ""))
|
||||
|
||||
if database_id is not None:
|
||||
ds_q = ds_q.where(SqlaTable.database_id == database_id)
|
||||
|
||||
return ds_q
|
||||
|
||||
@staticmethod
|
||||
def build_semantic_view_query(name_filter: str | None) -> Select:
|
||||
"""Build a SELECT for semantic views, applying name filter."""
|
||||
def build_semantic_view_query(
|
||||
name_filter: str | None,
|
||||
semantic_layer_uuid: str | None = None,
|
||||
) -> Select:
|
||||
"""Build a SELECT for semantic views, applying name and layer filters."""
|
||||
sv_q = select(
|
||||
SemanticView.id.label("item_id"),
|
||||
literal("semantic_layer").label("source_type"),
|
||||
@@ -137,6 +144,9 @@ class DatasourceDAO(BaseDAO[Datasource]):
|
||||
escaped = _escape_ilike_fragment(name_filter)
|
||||
sv_q = sv_q.where(SemanticView.name.ilike(f"%{escaped}%", escape="\\"))
|
||||
|
||||
if semantic_layer_uuid is not None:
|
||||
sv_q = sv_q.where(SemanticView.semantic_layer_uuid == semantic_layer_uuid)
|
||||
|
||||
return sv_q
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -21,23 +21,45 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import StatementError
|
||||
from superset_core.semantic_layers.daos import (
|
||||
AbstractSemanticLayerDAO,
|
||||
AbstractSemanticViewDAO,
|
||||
)
|
||||
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.extensions import db
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
class SemanticLayerDAO(AbstractSemanticLayerDAO):
|
||||
class SemanticLayerDAO(BaseDAO[SemanticLayer], AbstractSemanticLayerDAO):
|
||||
"""
|
||||
Data Access Object for SemanticLayer model.
|
||||
"""
|
||||
|
||||
# SemanticLayer uses uuid as the primary key
|
||||
id_column_name = "uuid"
|
||||
|
||||
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:
|
||||
"""
|
||||
@@ -93,7 +115,7 @@ class SemanticLayerDAO(AbstractSemanticLayerDAO):
|
||||
)
|
||||
|
||||
|
||||
class SemanticViewDAO(AbstractSemanticViewDAO):
|
||||
class SemanticViewDAO(BaseDAO[SemanticView], AbstractSemanticViewDAO):
|
||||
"""Data Access Object for SemanticView model."""
|
||||
|
||||
model_cls = SemanticView
|
||||
|
||||
@@ -66,6 +66,7 @@ from superset.superset_typing import FlaskResponse
|
||||
from superset.utils.core import is_test, pessimistic_connection_handling
|
||||
from superset.utils.decorators import transaction
|
||||
from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value
|
||||
from superset.utils.semantic_layer_labels import database_connections_menu_label
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.app import SupersetApp
|
||||
@@ -269,8 +270,12 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_api(RLSRestApi)
|
||||
appbuilder.add_api(SavedQueryRestApi)
|
||||
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
|
||||
from superset.semantic_layers.api import SemanticViewRestApi
|
||||
from superset.semantic_layers.api import (
|
||||
SemanticLayerRestApi,
|
||||
SemanticViewRestApi,
|
||||
)
|
||||
|
||||
appbuilder.add_api(SemanticLayerRestApi)
|
||||
appbuilder.add_api(SemanticViewRestApi)
|
||||
appbuilder.add_api(TagRestApi)
|
||||
appbuilder.add_api(SqlLabRestApi)
|
||||
@@ -304,7 +309,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_view(
|
||||
DatabaseView,
|
||||
"Databases",
|
||||
label=_("Database Connections"),
|
||||
label=database_connections_menu_label(),
|
||||
icon="fa-database",
|
||||
category="Data",
|
||||
category_label=_("Data"),
|
||||
|
||||
@@ -17,24 +17,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import request, Response
|
||||
from flask_appbuilder.api import expose, protect
|
||||
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 flask_babel import lazy_gettext as t, ngettext
|
||||
from marshmallow import ValidationError
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from superset import event_logger
|
||||
from superset import db, event_logger, is_feature_enabled
|
||||
from superset.commands.semantic_layer.create import (
|
||||
CreateSemanticLayerCommand,
|
||||
CreateSemanticViewCommand,
|
||||
)
|
||||
from superset.commands.semantic_layer.delete import (
|
||||
BulkDeleteSemanticViewCommand,
|
||||
DeleteSemanticLayerCommand,
|
||||
DeleteSemanticViewCommand,
|
||||
)
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
SemanticViewCreateFailedError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.commands.semantic_layer.update import UpdateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.update import (
|
||||
UpdateSemanticLayerCommand,
|
||||
UpdateSemanticViewCommand,
|
||||
)
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
from superset.semantic_layers.models import SemanticView
|
||||
from superset.semantic_layers.schemas import SemanticViewPutSchema
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO
|
||||
from superset.datasets.schemas import get_delete_ids_schema
|
||||
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,
|
||||
SemanticViewPostSchema,
|
||||
SemanticViewPutSchema,
|
||||
)
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import json
|
||||
from superset.views.base_api import (
|
||||
BaseSupersetApi,
|
||||
BaseSupersetModelRestApi,
|
||||
requires_json,
|
||||
statsd_metrics,
|
||||
@@ -43,6 +78,94 @@ from superset.views.base_api import (
|
||||
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)
|
||||
|
||||
@@ -50,10 +173,95 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
allow_browser_login = True
|
||||
class_permission_name = "SemanticView"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
include_route_methods = {"put"}
|
||||
include_route_methods = {"put", "post", "delete", "bulk_delete"}
|
||||
# SemanticViewRestApi exposes only write endpoints, but can_read must be
|
||||
# declared explicitly so that FAB registers the permission. It is used by
|
||||
# DatasourceRestApi.combined_list to gate access to semantic views in the
|
||||
# combined datasource list.
|
||||
base_permissions = ["can_read", "can_write"]
|
||||
|
||||
edit_model_schema = SemanticViewPutSchema()
|
||||
|
||||
@expose("/", methods=("POST",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@requires_json
|
||||
def post(self) -> Response:
|
||||
"""Bulk create semantic views.
|
||||
---
|
||||
post:
|
||||
summary: Create semantic views
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
views:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
semantic_layer_uuid:
|
||||
type: string
|
||||
configuration:
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
cache_timeout:
|
||||
type: integer
|
||||
responses:
|
||||
201:
|
||||
description: Semantic views created
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
body = request.json or {}
|
||||
views_data = body.get("views", [])
|
||||
if not views_data:
|
||||
return self.response_400(message="No views provided")
|
||||
|
||||
schema = SemanticViewPostSchema()
|
||||
created = []
|
||||
errors = []
|
||||
for view_data in views_data:
|
||||
try:
|
||||
item = schema.load(view_data)
|
||||
except ValidationError as error:
|
||||
errors.append({"name": view_data.get("name"), "error": error.messages})
|
||||
continue
|
||||
try:
|
||||
new_model = CreateSemanticViewCommand(item).run()
|
||||
created.append({"uuid": str(new_model.uuid), "name": new_model.name})
|
||||
except SemanticLayerNotFoundError:
|
||||
errors.append(
|
||||
{"name": view_data.get("name"), "error": "Semantic layer not found"}
|
||||
)
|
||||
except SemanticViewCreateFailedError as ex:
|
||||
logger.error(
|
||||
"Error creating semantic view: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
errors.append({"name": view_data.get("name"), "error": str(ex)})
|
||||
|
||||
result: dict[str, Any] = {"created": created}
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
status = 201 if created else 422
|
||||
return self.response(status, result=result)
|
||||
|
||||
@expose("/<pk>", methods=("PUT",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@@ -126,3 +334,686 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
)
|
||||
response = self.response_422(message=str(ex))
|
||||
return response
|
||||
|
||||
@expose("/<pk>", methods=("DELETE",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def delete(self, pk: int) -> Response:
|
||||
"""Delete a semantic view.
|
||||
---
|
||||
delete:
|
||||
summary: Delete a semantic view
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
responses:
|
||||
200:
|
||||
description: Semantic view deleted
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
try:
|
||||
DeleteSemanticViewCommand(pk).run()
|
||||
return self.response(200, message="OK")
|
||||
except SemanticViewNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticViewForbiddenError:
|
||||
return self.response_403()
|
||||
except SemanticViewDeleteFailedError as ex:
|
||||
logger.error(
|
||||
"Error deleting semantic view: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/", methods=("DELETE",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@rison(get_delete_ids_schema)
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def bulk_delete(self, **kwargs: Any) -> Response:
|
||||
"""Bulk delete semantic views.
|
||||
---
|
||||
delete:
|
||||
summary: Bulk delete semantic views
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/get_delete_ids_schema'
|
||||
responses:
|
||||
200:
|
||||
description: Semantic views deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
item_ids: list[int] = kwargs["rison"]
|
||||
try:
|
||||
BulkDeleteSemanticViewCommand(item_ids).run()
|
||||
return self.response(
|
||||
200,
|
||||
message=ngettext(
|
||||
"Deleted %(num)d semantic view",
|
||||
"Deleted %(num)d semantic views",
|
||||
num=len(item_ids),
|
||||
),
|
||||
)
|
||||
except SemanticViewNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticViewForbiddenError:
|
||||
return self.response_403()
|
||||
except SemanticViewDeleteFailedError as ex:
|
||||
logger.error(
|
||||
"Error bulk deleting semantic views: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
schema = cls.get_configuration_schema(parsed_config)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# 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)
|
||||
|
||||
resp = make_response(json.dumps({"result": schema}, 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("/<uuid>/views", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
def views(self, uuid: str) -> FlaskResponse:
|
||||
"""List available views from a semantic layer.
|
||||
---
|
||||
post:
|
||||
summary: List available views from 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: Available views
|
||||
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", {})
|
||||
|
||||
try:
|
||||
views = layer.implementation.get_semantic_views(runtime_data)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.error(
|
||||
"Error fetching semantic views for layer %s: %s",
|
||||
uuid,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_400(
|
||||
message=t(
|
||||
"Unable to fetch semantic views. Check the layer configuration."
|
||||
)
|
||||
)
|
||||
|
||||
# Check which views already exist with the same runtime config
|
||||
existing = SemanticLayerDAO.get_semantic_views(str(layer.uuid))
|
||||
existing_keys: set[tuple[str, str]] = set()
|
||||
for v in existing:
|
||||
config = v.configuration
|
||||
if isinstance(config, str):
|
||||
config = json.loads(config)
|
||||
existing_keys.add((v.name, json.dumps(config or {}, sort_keys=True)))
|
||||
runtime_key = json.dumps(runtime_data or {}, sort_keys=True)
|
||||
|
||||
result = [
|
||||
{
|
||||
"name": v.name,
|
||||
"already_added": (v.name, runtime_key) in existing_keys,
|
||||
}
|
||||
for v in sorted(views, key=lambda v: v.name)
|
||||
]
|
||||
return self.response(200, result=result)
|
||||
|
||||
@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"):
|
||||
source_type = "database"
|
||||
|
||||
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)
|
||||
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)
|
||||
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()]
|
||||
|
||||
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))
|
||||
|
||||
@@ -291,11 +291,7 @@ def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]:
|
||||
|
||||
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
|
||||
)
|
||||
grain = _convert_time_grain(query_object.extras.get("time_grain_sqla"))
|
||||
dimensions = [
|
||||
dimension
|
||||
for dimension in semantic_view.dimensions
|
||||
@@ -740,13 +736,17 @@ def _get_group_limit_filters(
|
||||
return filters if filters else None
|
||||
|
||||
|
||||
def _convert_time_grain(time_grain: str) -> Grain | None:
|
||||
def _convert_time_grain(time_grain: str | None) -> Grain | None:
|
||||
"""
|
||||
Convert a time grain string (ISO 8601 duration) to a Grain instance.
|
||||
|
||||
Returns None when ``time_grain`` is None or empty (no grain selected).
|
||||
"""
|
||||
if not time_grain:
|
||||
return None
|
||||
try:
|
||||
return Grains.get(time_grain)
|
||||
except (ValueError, isodate.ISO8601Error):
|
||||
except (TypeError, ValueError, isodate.ISO8601Error):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
for metric in self.implementation.get_metrics()
|
||||
],
|
||||
"database": {},
|
||||
"parent": {"name": self.semantic_layer.name},
|
||||
# UI features
|
||||
"verbose_map": {},
|
||||
"order_by_choices": [],
|
||||
|
||||
@@ -20,3 +20,26 @@ 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)
|
||||
|
||||
|
||||
class SemanticViewPostSchema(Schema):
|
||||
name = fields.String(required=True)
|
||||
semantic_layer_uuid = fields.String(required=True)
|
||||
configuration = fields.Dict(load_default=dict)
|
||||
description = fields.String(allow_none=True)
|
||||
cache_timeout = fields.Integer(allow_none=True)
|
||||
|
||||
@@ -299,6 +299,7 @@ class ExplorableData(TypedDict, total=False):
|
||||
column_formats: dict[str, str | None]
|
||||
description: str | None
|
||||
database: dict[str, Any]
|
||||
parent: dict[str, Any]
|
||||
default_endpoint: str | None
|
||||
filter_select: bool
|
||||
filter_select_enabled: bool
|
||||
|
||||
105
superset/utils/semantic_layer_labels.py
Normal file
105
superset/utils/semantic_layer_labels.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Label helpers for SEMANTIC_LAYERS feature flag.
|
||||
|
||||
When the SEMANTIC_LAYERS feature flag is enabled the UI broadens
|
||||
"dataset" → "datasource" and "database" → "data connection" so
|
||||
that semantic views and semantic layers feel like first-class
|
||||
citizens alongside traditional datasets and database connections.
|
||||
|
||||
Mirror of superset-frontend/src/utils/semanticLayerLabels.ts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_babel import lazy_gettext as _
|
||||
|
||||
|
||||
def _sl(legacy: str, semantic: str) -> str:
|
||||
"""Return *semantic* when SEMANTIC_LAYERS is enabled, else *legacy*."""
|
||||
# Imported lazily to avoid a circular import at module load time
|
||||
# (superset.utils.semantic_layer_labels is imported by superset.initialization,
|
||||
# which is itself imported during superset package initialization).
|
||||
from superset import feature_flag_manager # pylint: disable=import-outside-toplevel
|
||||
|
||||
return (
|
||||
semantic
|
||||
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS")
|
||||
else legacy
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# "dataset" family
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dataset_label() -> str:
|
||||
"""Capitalized singular: "Dataset" / "Datasource" """
|
||||
return _sl(_("Dataset"), _("Datasource"))
|
||||
|
||||
|
||||
def dataset_label_lower() -> str:
|
||||
"""Lower-case singular: "dataset" / "datasource" """
|
||||
return _sl(_("dataset"), _("datasource"))
|
||||
|
||||
|
||||
def datasets_label() -> str:
|
||||
"""Capitalized plural: "Datasets" / "Datasources" """
|
||||
return _sl(_("Datasets"), _("Datasources"))
|
||||
|
||||
|
||||
def datasets_label_lower() -> str:
|
||||
"""Lower-case plural: "datasets" / "datasources" """
|
||||
return _sl(_("datasets"), _("datasources"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# "database" family
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def database_label() -> str:
|
||||
"""Capitalized singular: "Database" / "Data connection" """
|
||||
return _sl(_("Database"), _("Data connection"))
|
||||
|
||||
|
||||
def database_label_lower() -> str:
|
||||
"""Lower-case singular: "database" / "data connection" """
|
||||
return _sl(_("database"), _("data connection"))
|
||||
|
||||
|
||||
def databases_label() -> str:
|
||||
"""Capitalized plural: "Databases" / "Data connections" """
|
||||
return _sl(_("Databases"), _("Data connections"))
|
||||
|
||||
|
||||
def databases_label_lower() -> str:
|
||||
"""Lower-case plural: "databases" / "data connections" """
|
||||
return _sl(_("databases"), _("data connections"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Menu label (includes the word "Connections")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def database_connections_menu_label() -> str:
|
||||
"""Menu entry label: "Database Connections" / "Data Connections" """
|
||||
return _sl(_("Database Connections"), _("Data Connections"))
|
||||
230
tests/unit_tests/commands/semantic_layer/create_test.py
Normal file
230
tests/unit_tests/commands/semantic_layer/create_test.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerInvalidError,
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful creation of a semantic layer."""
|
||||
new_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao.validate_uniqueness.return_value = True
|
||||
dao.create.return_value = new_model
|
||||
|
||||
mock_cls = MagicMock()
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.create.registry",
|
||||
{"snowflake": mock_cls},
|
||||
)
|
||||
|
||||
data = {
|
||||
"name": "My Layer",
|
||||
"type": "snowflake",
|
||||
"configuration": {"account": "test"},
|
||||
}
|
||||
result = CreateSemanticLayerCommand(data).run()
|
||||
|
||||
assert result == new_model
|
||||
expected = {**data, "configuration": '{"account": "test"}'}
|
||||
dao.create.assert_called_once_with(attributes=expected)
|
||||
mock_cls.from_configuration.assert_called_once_with({"account": "test"})
|
||||
|
||||
|
||||
def test_create_semantic_layer_unknown_type(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerInvalidError is raised for unknown type."""
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.create.registry",
|
||||
{},
|
||||
clear=True,
|
||||
)
|
||||
|
||||
data = {
|
||||
"name": "My Layer",
|
||||
"type": "nonexistent",
|
||||
"configuration": {},
|
||||
}
|
||||
with pytest.raises(SemanticLayerInvalidError):
|
||||
CreateSemanticLayerCommand(data).run()
|
||||
|
||||
|
||||
def test_create_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerInvalidError is raised for duplicate names."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao.validate_uniqueness.return_value = False
|
||||
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.create.registry",
|
||||
{"snowflake": MagicMock()},
|
||||
)
|
||||
|
||||
data = {
|
||||
"name": "Duplicate",
|
||||
"type": "snowflake",
|
||||
"configuration": {},
|
||||
}
|
||||
with pytest.raises(SemanticLayerInvalidError):
|
||||
CreateSemanticLayerCommand(data).run()
|
||||
|
||||
|
||||
def test_create_semantic_layer_invalid_configuration(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that invalid configuration is caught by the @transaction decorator."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao.validate_uniqueness.return_value = True
|
||||
|
||||
mock_cls = MagicMock()
|
||||
mock_cls.from_configuration.side_effect = ValueError("bad config")
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.create.registry",
|
||||
{"snowflake": mock_cls},
|
||||
)
|
||||
|
||||
data = {
|
||||
"name": "My Layer",
|
||||
"type": "snowflake",
|
||||
"configuration": {"bad": "data"},
|
||||
}
|
||||
with pytest.raises(SemanticLayerCreateFailedError):
|
||||
CreateSemanticLayerCommand(data).run()
|
||||
|
||||
|
||||
def test_create_semantic_layer_copies_data(mocker: MockerFixture) -> None:
|
||||
"""Test that the command copies input data and does not mutate it."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao.validate_uniqueness.return_value = True
|
||||
dao.create.return_value = MagicMock()
|
||||
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.create.registry",
|
||||
{"snowflake": MagicMock()},
|
||||
)
|
||||
|
||||
original_data = {
|
||||
"name": "Original",
|
||||
"type": "snowflake",
|
||||
"configuration": {"account": "test"},
|
||||
}
|
||||
CreateSemanticLayerCommand(original_data).run()
|
||||
|
||||
assert original_data == {
|
||||
"name": "Original",
|
||||
"type": "snowflake",
|
||||
"configuration": {"account": "test"},
|
||||
}
|
||||
|
||||
|
||||
def test_create_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful creation of a semantic view."""
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = True
|
||||
mock_model = MagicMock()
|
||||
mock_model.uuid = "new-uuid"
|
||||
mock_model.name = "orders"
|
||||
dao_view.create.return_value = mock_model
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
|
||||
result = CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao_view.validate_uniqueness.assert_called_once_with(
|
||||
"orders", "layer-uuid", {"db": "prod"}
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_view_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test CreateSemanticViewCommand raises when layer not found."""
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerNotFoundError,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticLayerNotFoundError):
|
||||
CreateSemanticViewCommand({"name": "v", "semantic_layer_uuid": "missing"}).run()
|
||||
|
||||
|
||||
def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
|
||||
"""Test CreateSemanticViewCommand raises on duplicate view."""
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = False
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewCreateFailedError,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewCreateFailedError):
|
||||
CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
164
tests/unit_tests/commands/semantic_layer/delete_test.py
Normal file
164
tests/unit_tests/commands/semantic_layer/delete_test.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
|
||||
|
||||
|
||||
def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful deletion of a semantic layer."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.find_by_uuid.assert_called_once_with("some-uuid")
|
||||
dao.delete.assert_called_once_with([mock_model])
|
||||
|
||||
|
||||
def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerNotFoundError is raised when model is missing."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = None
|
||||
|
||||
with pytest.raises(SemanticLayerNotFoundError):
|
||||
DeleteSemanticLayerCommand("missing-uuid").run()
|
||||
|
||||
|
||||
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful deletion of a semantic view."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
# Admin is owner of everything — no exception raised
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.security_manager"
|
||||
).raise_for_ownership.return_value = None
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
dao.find_by_id.assert_called_once_with(42, id_column="id")
|
||||
dao.delete.assert_called_once_with([mock_model])
|
||||
|
||||
|
||||
def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewForbiddenError is raised for non-owners."""
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = MagicMock()
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.raise_for_ownership",
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
|
||||
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewNotFoundError is raised when view is missing."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = None
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewNotFoundError):
|
||||
DeleteSemanticViewCommand(999).run()
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful bulk deletion of semantic views."""
|
||||
mock_models = [MagicMock(), MagicMock()]
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_ids.return_value = mock_models
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.security_manager"
|
||||
).raise_for_ownership.return_value = None
|
||||
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
dao.find_by_ids.assert_called_once_with([1, 2], id_column="id")
|
||||
dao.delete.assert_called_once_with(mock_models)
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewForbiddenError is raised for non-owners."""
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_ids.return_value = [MagicMock(), MagicMock()]
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.raise_for_ownership",
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewNotFoundError is raised when any id is missing."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
# Only one model returned for two requested ids
|
||||
dao.find_by_ids.return_value = [MagicMock()]
|
||||
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewNotFoundError
|
||||
|
||||
with pytest.raises(SemanticViewNotFoundError):
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
@@ -16,6 +16,12 @@
|
||||
# under the License.
|
||||
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewNotFoundError,
|
||||
@@ -46,3 +52,40 @@ def test_semantic_view_update_failed_error() -> None:
|
||||
"""Test SemanticViewUpdateFailedError has correct message."""
|
||||
error = SemanticViewUpdateFailedError()
|
||||
assert str(error.message) == "Semantic view could not be updated."
|
||||
|
||||
|
||||
def test_semantic_layer_not_found_error() -> None:
|
||||
"""Test SemanticLayerNotFoundError has correct status and message."""
|
||||
error = SemanticLayerNotFoundError()
|
||||
assert error.status == 404
|
||||
assert str(error.message) == "Semantic layer does not exist"
|
||||
|
||||
|
||||
def test_semantic_layer_forbidden_error() -> None:
|
||||
"""Test SemanticLayerForbiddenError has correct message."""
|
||||
error = SemanticLayerForbiddenError()
|
||||
assert str(error.message) == "Changing this semantic layer is forbidden"
|
||||
|
||||
|
||||
def test_semantic_layer_invalid_error() -> None:
|
||||
"""Test SemanticLayerInvalidError has correct message."""
|
||||
error = SemanticLayerInvalidError()
|
||||
assert str(error.message) == "Semantic layer parameters are invalid."
|
||||
|
||||
|
||||
def test_semantic_layer_create_failed_error() -> None:
|
||||
"""Test SemanticLayerCreateFailedError has correct message."""
|
||||
error = SemanticLayerCreateFailedError()
|
||||
assert str(error.message) == "Semantic layer could not be created."
|
||||
|
||||
|
||||
def test_semantic_layer_update_failed_error() -> None:
|
||||
"""Test SemanticLayerUpdateFailedError has correct message."""
|
||||
error = SemanticLayerUpdateFailedError()
|
||||
assert str(error.message) == "Semantic layer could not be updated."
|
||||
|
||||
|
||||
def test_semantic_layer_delete_failed_error() -> None:
|
||||
"""Test SemanticLayerDeleteFailedError has correct message."""
|
||||
error = SemanticLayerDeleteFailedError()
|
||||
assert str(error.message) == "Semantic layer could not be deleted."
|
||||
|
||||
@@ -21,10 +21,15 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.commands.semantic_layer.update import UpdateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.update import (
|
||||
UpdateSemanticLayerCommand,
|
||||
UpdateSemanticViewCommand,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
@@ -106,6 +111,116 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
|
||||
assert original_data == {"description": "Original"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UpdateSemanticLayerCommand tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful update of a semantic layer."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
data = {"name": "Updated", "description": "New desc"}
|
||||
result = UpdateSemanticLayerCommand("some-uuid", data).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao.find_by_uuid.assert_called_once_with("some-uuid")
|
||||
dao.update.assert_called_once_with(mock_model, attributes=data)
|
||||
|
||||
|
||||
def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerNotFoundError is raised when model is missing."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = None
|
||||
|
||||
with pytest.raises(SemanticLayerNotFoundError):
|
||||
UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
|
||||
|
||||
|
||||
def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerInvalidError is raised for duplicate names."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.validate_update_uniqueness.return_value = False
|
||||
|
||||
with pytest.raises(SemanticLayerInvalidError):
|
||||
UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
|
||||
|
||||
|
||||
def test_update_semantic_layer_validates_configuration(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that configuration is validated against the plugin."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mock_cls = MagicMock()
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.update.registry",
|
||||
{"snowflake": mock_cls},
|
||||
)
|
||||
|
||||
config = {"account": "test"}
|
||||
UpdateSemanticLayerCommand("some-uuid", {"configuration": config}).run()
|
||||
|
||||
mock_cls.from_configuration.assert_called_once_with(config)
|
||||
|
||||
|
||||
def test_update_semantic_layer_skips_name_check_when_no_name(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that name uniqueness is not checked when name is not provided."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
|
||||
|
||||
dao.validate_update_uniqueness.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
|
||||
"""Test that the command copies input data and does not mutate it."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
original_data = {"description": "Original"}
|
||||
UpdateSemanticLayerCommand("some-uuid", original_data).run()
|
||||
|
||||
assert original_data == {"description": "Original"}
|
||||
|
||||
|
||||
def _make_view_model(
|
||||
uuid: str = "view-uuid-1",
|
||||
name: str = "my_view",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -560,12 +560,18 @@ def test_semantic_view_data(
|
||||
mock_metrics: list[Metric],
|
||||
) -> None:
|
||||
"""Test SemanticView data property."""
|
||||
from superset.semantic_layers.models import SemanticLayer
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "My Semantic Layer"
|
||||
|
||||
view = SemanticView()
|
||||
view.name = "Orders View"
|
||||
view.description = "View of order data"
|
||||
view.id = 1
|
||||
view.uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
||||
view.semantic_layer_uuid = uuid.UUID("87654321-4321-8765-4321-876543218765")
|
||||
view.semantic_layer = layer
|
||||
view.cache_timeout = 3600
|
||||
|
||||
with patch.object(
|
||||
@@ -582,6 +588,8 @@ def test_semantic_view_data(
|
||||
assert data["name"] == "Orders View"
|
||||
assert data["description"] == "View of order data"
|
||||
assert data["cache_timeout"] == 3600
|
||||
assert data["database"] == {}
|
||||
assert data["parent"] == {"name": "My Semantic Layer"}
|
||||
|
||||
# Check columns
|
||||
assert len(data["columns"]) == 2
|
||||
@@ -637,11 +645,18 @@ def test_semantic_view_data_for_slices(
|
||||
mock_metrics: list[Metric],
|
||||
) -> None:
|
||||
"""Test SemanticView data_for_slices returns same as data."""
|
||||
from superset.semantic_layers.models import SemanticLayer
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "My Semantic Layer"
|
||||
|
||||
view = SemanticView()
|
||||
view.name = "Orders View"
|
||||
view.description = "View of order data"
|
||||
view.id = 1
|
||||
view.uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
||||
view.semantic_layer_uuid = uuid.UUID("87654321-4321-8765-4321-876543218765")
|
||||
view.semantic_layer = layer
|
||||
view.cache_timeout = 3600
|
||||
|
||||
with patch.object(
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
import pytest
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.semantic_layers.schemas import SemanticViewPutSchema
|
||||
from superset.semantic_layers.schemas import (
|
||||
SemanticLayerPostSchema,
|
||||
SemanticLayerPutSchema,
|
||||
SemanticViewPutSchema,
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_view_put_schema_both_fields() -> None:
|
||||
@@ -77,3 +81,128 @@ def test_semantic_view_put_schema_unknown_field() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load({"unknown_field": "value"})
|
||||
assert "unknown_field" in exc_info.value.messages
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticLayerPostSchema tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_post_schema_all_fields() -> None:
|
||||
"""Test loading all fields."""
|
||||
schema = SemanticLayerPostSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"name": "My Layer",
|
||||
"description": "A layer",
|
||||
"type": "snowflake",
|
||||
"configuration": {"account": "test"},
|
||||
"cache_timeout": 300,
|
||||
}
|
||||
)
|
||||
assert result["name"] == "My Layer"
|
||||
assert result["type"] == "snowflake"
|
||||
assert result["configuration"] == {"account": "test"}
|
||||
assert result["cache_timeout"] == 300
|
||||
|
||||
|
||||
def test_post_schema_required_fields_only() -> None:
|
||||
"""Test loading with only required fields."""
|
||||
schema = SemanticLayerPostSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"name": "My Layer",
|
||||
"type": "snowflake",
|
||||
"configuration": {"account": "test"},
|
||||
}
|
||||
)
|
||||
assert result["name"] == "My Layer"
|
||||
assert "description" not in result
|
||||
assert "cache_timeout" not in result
|
||||
|
||||
|
||||
def test_post_schema_missing_name() -> None:
|
||||
"""Test that missing name raises ValidationError."""
|
||||
schema = SemanticLayerPostSchema()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load({"type": "snowflake", "configuration": {}})
|
||||
assert "name" in exc_info.value.messages
|
||||
|
||||
|
||||
def test_post_schema_missing_type() -> None:
|
||||
"""Test that missing type raises ValidationError."""
|
||||
schema = SemanticLayerPostSchema()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load({"name": "My Layer", "configuration": {}})
|
||||
assert "type" in exc_info.value.messages
|
||||
|
||||
|
||||
def test_post_schema_missing_configuration() -> None:
|
||||
"""Test that missing configuration raises ValidationError."""
|
||||
schema = SemanticLayerPostSchema()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load({"name": "My Layer", "type": "snowflake"})
|
||||
assert "configuration" in exc_info.value.messages
|
||||
|
||||
|
||||
def test_post_schema_null_description() -> None:
|
||||
"""Test that description accepts None."""
|
||||
schema = SemanticLayerPostSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"name": "My Layer",
|
||||
"type": "snowflake",
|
||||
"configuration": {},
|
||||
"description": None,
|
||||
}
|
||||
)
|
||||
assert result["description"] is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticLayerPutSchema tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_put_schema_all_fields() -> None:
|
||||
"""Test loading all fields."""
|
||||
schema = SemanticLayerPutSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"name": "Updated",
|
||||
"description": "New desc",
|
||||
"configuration": {"account": "new"},
|
||||
"cache_timeout": 600,
|
||||
}
|
||||
)
|
||||
assert result["name"] == "Updated"
|
||||
assert result["configuration"] == {"account": "new"}
|
||||
|
||||
|
||||
def test_put_schema_empty() -> None:
|
||||
"""Test loading empty payload."""
|
||||
schema = SemanticLayerPutSchema()
|
||||
result = schema.load({})
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_put_schema_name_only() -> None:
|
||||
"""Test loading with only name."""
|
||||
schema = SemanticLayerPutSchema()
|
||||
result = schema.load({"name": "New Name"})
|
||||
assert result == {"name": "New Name"}
|
||||
|
||||
|
||||
def test_put_schema_configuration_only() -> None:
|
||||
"""Test loading with only configuration."""
|
||||
schema = SemanticLayerPutSchema()
|
||||
result = schema.load({"configuration": {"key": "value"}})
|
||||
assert result == {"configuration": {"key": "value"}}
|
||||
|
||||
|
||||
def test_put_schema_unknown_field() -> None:
|
||||
"""Test that unknown fields raise ValidationError."""
|
||||
schema = SemanticLayerPutSchema()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load({"unknown_field": "value"})
|
||||
assert "unknown_field" in exc_info.value.messages
|
||||
|
||||
Reference in New Issue
Block a user