You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2026/03/04 02:00:51 UTC
(superset) branch semantic-layer-add-semantic-view updated (ab95e17b546 -> 82b0eb18dda)
This is an automated email from the ASF dual-hosted git repository.
beto pushed a change to branch semantic-layer-add-semantic-view
in repository https://gitbox.apache.org/repos/asf/superset.git
discard ab95e17b546 feat: CRUD for adding/deleting semantic views
omit eccfceb31f9 feat(semantic layers): UI for semantic layers
add 34d081ec1e8 feat(semantic layers): UI for semantic layers
new 82b0eb18dda feat: CRUD for adding/deleting semantic views
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (ab95e17b546)
\
N -- N -- N refs/heads/semantic-layer-add-semantic-view (82b0eb18dda)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
superset-frontend/package-lock.json | 215 ++++++++++++++++++++++++++++++++++--
1 file changed, 208 insertions(+), 7 deletions(-)
(superset) 01/01: feat: CRUD for adding/deleting semantic views
Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
beto pushed a commit to branch semantic-layer-add-semantic-view
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 82b0eb18ddaa467197f412403f99a08847e6b3c6
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Tue Feb 17 11:50:06 2026 -0500
feat: CRUD for adding/deleting semantic views
---
.../features/semanticLayers/SemanticLayerModal.tsx | 265 ++----------
.../features/semanticLayers/jsonFormsHelpers.tsx | 257 +++++++++++
.../semanticViews/AddSemanticViewModal.tsx | 475 +++++++++++++++++++++
superset-frontend/src/pages/DatasetList/index.tsx | 173 ++++++--
superset/commands/semantic_layer/create.py | 31 +-
superset/commands/semantic_layer/delete.py | 29 +-
superset/commands/semantic_layer/exceptions.py | 8 +
superset/daos/semantic_layer.py | 24 +-
superset/semantic_layers/api.py | 300 ++++++++++---
superset/semantic_layers/schemas.py | 8 +
10 files changed, 1245 insertions(+), 325 deletions(-)
diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
index 37694a9e9a6..b0e1a58431d 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
@@ -20,27 +20,12 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { t } from '@apache-superset/core';
import { styled } from '@apache-superset/core/ui';
import { SupersetClient } from '@superset-ui/core';
-import { Input, Spin } from 'antd';
+import { Input } from 'antd';
import { Select } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
-import { JsonForms, withJsonFormsControlProps } from '@jsonforms/react';
-import type {
- JsonSchema,
- UISchemaElement,
- ControlProps,
-} from '@jsonforms/core';
-import {
- rankWith,
- and,
- isStringControl,
- formatIs,
- schemaMatches,
-} from '@jsonforms/core';
-import {
- rendererRegistryEntries,
- cellRegistryEntries,
- TextControl,
-} from '@great-expectations/jsonforms-antd-renderers';
+import { 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,
@@ -48,229 +33,19 @@ import {
MODAL_STANDARD_WIDTH,
MODAL_MEDIUM_WIDTH,
} from 'src/components/Modal';
-
-/**
- * Custom renderer that renders `Input.Password` for fields with
- * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
- */
-function PasswordControl(props: ControlProps) {
- const uischema = {
- ...props.uischema,
- options: { ...props.uischema.options, type: 'password' },
- };
- return TextControl({ ...props, uischema });
-}
-const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
-const passwordEntry = {
- tester: rankWith(3, and(isStringControl, formatIs('password'))),
- renderer: PasswordRenderer,
-};
-
-/**
- * Renderer for `const` properties (e.g. Pydantic discriminator fields).
- * Renders nothing visually but ensures the const value is set in form data,
- * so discriminated unions resolve correctly on the backend.
- */
-function ConstControl({ data, handleChange, path, schema }: ControlProps) {
- const constValue = (schema as Record<string, unknown>).const;
- useEffect(() => {
- if (constValue !== undefined && data !== constValue) {
- handleChange(path, constValue);
- }
- }, [constValue, data, handleChange, path]);
- return null;
-}
-const ConstRenderer = withJsonFormsControlProps(ConstControl);
-const constEntry = {
- tester: rankWith(10, schemaMatches(s => s !== undefined && 'const' in s)),
- renderer: ConstRenderer,
-};
-
-/**
- * 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,
-};
-
-const renderers = [
- ...rendererRegistryEntries,
- passwordEntry,
- constEntry,
- dynamicFieldEntry,
-];
+import {
+ renderers,
+ sanitizeSchema,
+ buildUiSchema,
+ getDynamicDependencies,
+ areDependenciesSatisfied,
+ serializeDependencyValues,
+ SCHEMA_REFRESH_DEBOUNCE_MS,
+} from './jsonFormsHelpers';
type Step = 'type' | 'config';
type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow';
-const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
-
-/**
- * Removes empty `enum` arrays from schema properties. The JSON Schema spec
- * requires `enum` to have at least one item, and AJV rejects empty arrays.
- * Fields with empty enums are rendered as plain text inputs instead.
- */
-function sanitizeSchema(schema: JsonSchema): JsonSchema {
- if (!schema.properties) return schema;
- const properties: Record<string, JsonSchema> = {};
- for (const [key, prop] of Object.entries(schema.properties)) {
- if (
- typeof prop === 'object' &&
- prop !== null &&
- 'enum' in prop &&
- Array.isArray(prop.enum) &&
- prop.enum.length === 0
- ) {
- const { enum: _empty, ...rest } = prop;
- properties[key] = rest;
- } else {
- properties[key] = prop as JsonSchema;
- }
- }
- return { ...schema, properties };
-}
-
-/**
- * Builds a JSON Forms UI schema from a JSON Schema, using the first
- * `examples` entry as placeholder text for each string property.
- */
-function buildUiSchema(
- schema: JsonSchema,
-): UISchemaElement | undefined {
- if (!schema.properties) return undefined;
-
- // Use explicit property order from backend if available,
- // otherwise fall back to the JSON object key order
- const propertyOrder: string[] =
- (schema as Record<string, unknown>)['x-propertyOrder'] as string[] ??
- Object.keys(schema.properties);
-
- const elements = propertyOrder
- .filter(key => key in (schema.properties ?? {}))
- .map(key => {
- const prop = schema.properties![key];
- const control: Record<string, unknown> = {
- type: 'Control',
- scope: `#/properties/${key}`,
- };
- if (typeof prop === 'object' && prop !== null) {
- const options: Record<string, unknown> = {};
- if (
- 'examples' in prop &&
- Array.isArray(prop.examples) &&
- prop.examples.length > 0
- ) {
- options.placeholderText = String(prop.examples[0]);
- }
- if ('description' in prop && typeof prop.description === 'string') {
- options.tooltip = prop.description;
- }
- if (Object.keys(options).length > 0) {
- control.options = options;
- }
- }
- return control;
- });
- return { type: 'VerticalLayout', elements } as UISchemaElement;
-}
-
-/**
- * Extracts dynamic field dependency mappings from the schema.
- * Returns a map of field name → list of dependency field names.
- */
-function getDynamicDependencies(
- schema: JsonSchema,
-): Record<string, string[]> {
- const deps: Record<string, string[]> = {};
- if (!schema.properties) return deps;
- for (const [key, prop] of Object.entries(schema.properties)) {
- if (
- typeof prop === 'object' &&
- prop !== null &&
- 'x-dynamic' in prop &&
- 'x-dependsOn' in prop &&
- Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
- ) {
- deps[key] = (prop as Record<string, unknown>)[
- 'x-dependsOn'
- ] as string[];
- }
- }
- return deps;
-}
-
-/**
- * Checks whether all dependency values are filled (non-empty).
- * Handles nested objects (like auth) by checking they have at least one key.
- */
-function areDependenciesSatisfied(
- dependencies: string[],
- data: Record<string, unknown>,
-): boolean {
- return dependencies.every(dep => {
- const value = data[dep];
- if (value === null || value === undefined || value === '') return false;
- if (typeof value === 'object' && Object.keys(value).length === 0)
- return false;
- return true;
- });
-}
-
-/**
- * Serializes the dependency values for a set of fields into a stable string
- * for comparison, so we only re-fetch when dependency values actually change.
- */
-function serializeDependencyValues(
- dynamicDeps: Record<string, string[]>,
- data: Record<string, unknown>,
-): string {
- const allDepKeys = new Set<string>();
- for (const deps of Object.values(dynamicDeps)) {
- for (const dep of deps) {
- allDepKeys.add(dep);
- }
- }
- const snapshot: Record<string, unknown> = {};
- for (const key of [...allDepKeys].sort()) {
- snapshot[key] = data[key];
- }
- return JSON.stringify(snapshot);
-}
-
const ModalContent = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 4}px;
`;
@@ -395,7 +170,7 @@ export default function SemanticLayerModal({
setSelectedType(layer.type);
setFormData(layer.configuration ?? {});
setHasErrors(false);
- // Fetch base schema (no configuration → no Snowflake connection) to
+ // 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.
@@ -526,7 +301,13 @@ export default function SemanticLayerModal({
);
const handleFormChange = useCallback(
- ({ data, errors }: { data: Record<string, unknown>; errors?: ErrorObject[] }) => {
+ ({
+ data,
+ errors,
+ }: {
+ data: Record<string, unknown>;
+ errors?: ErrorObject[];
+ }) => {
setFormData(data);
errorsRef.current = errors ?? [];
setHasErrors(errorsRef.current.length > 0);
@@ -561,7 +342,9 @@ export default function SemanticLayerModal({
saveDisabled={
step === 'type' ? !selectedType : saving || !name.trim() || hasErrors
}
- saveText={step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')}
+ saveText={
+ step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')
+ }
saveLoading={saving}
contentLoading={loading}
>
diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
new file mode 100644
index 00000000000..6b21e73e9ca
--- /dev/null
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -0,0 +1,257 @@
+/**
+ * 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';
+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)),
+ renderer: ConstRenderer,
+};
+
+/**
+ * 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,
+ 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 };
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+ });
+}
+
+/**
+ * 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);
+}
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
new file mode 100644
index 00000000000..505eab8f18b
--- /dev/null
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -0,0 +1,475 @@
+/**
+ * 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';
+import { styled } from '@apache-superset/core/ui';
+import { SupersetClient } from '@superset-ui/core';
+import { Checkbox, 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,
+ MODAL_MEDIUM_WIDTH,
+} from 'src/components/Modal';
+import {
+ renderers,
+ sanitizeSchema,
+ buildUiSchema,
+ getDynamicDependencies,
+ areDependenciesSatisfied,
+ serializeDependencyValues,
+ SCHEMA_REFRESH_DEBOUNCE_MS,
+} from 'src/features/semanticLayers/jsonFormsHelpers';
+
+type Step = 'layer' | 'configure' | 'select';
+
+interface SemanticLayerOption {
+ uuid: string;
+ name: string;
+}
+
+interface AvailableView {
+ name: string;
+ already_added: boolean;
+}
+
+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[1]}px;
+ margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
+ display: inline-flex;
+ align-items: center;
+ gap: ${({ theme }) => theme.sizeUnit}px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+const ViewList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.sizeUnit}px;
+`;
+
+const LoadingContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ padding: ${({ theme }) => theme.sizeUnit * 6}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) {
+ const [step, setStep] = useState<Step>('layer');
+ const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
+ const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
+ null,
+ );
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ // Step 2: Configure (runtime schema)
+ const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
+ const [runtimeUiSchema, setRuntimeUiSchema] = useState<
+ UISchemaElement | undefined
+ >(undefined);
+ const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
+ const [refreshingSchema, setRefreshingSchema] = useState(false);
+ const [hasRuntimeErrors, setHasRuntimeErrors] = useState(false);
+ const errorsRef = useRef<ErrorObject[]>([]);
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const lastDepSnapshotRef = useRef<string>('');
+ const dynamicDepsRef = useRef<Record<string, string[]>>({});
+
+ // Step 3: Select views
+ const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
+ const [selectedViews, setSelectedViews] = useState<Set<string>>(new Set());
+ const [loadingViews, setLoadingViews] = useState(false);
+
+ // Reset state when modal closes
+ useEffect(() => {
+ if (show) {
+ fetchLayers();
+ } else {
+ setStep('layer');
+ setLayers([]);
+ setSelectedLayerUuid(null);
+ setLoading(false);
+ setSaving(false);
+ setRuntimeSchema(null);
+ setRuntimeUiSchema(undefined);
+ setRuntimeData({});
+ setRefreshingSchema(false);
+ setHasRuntimeErrors(false);
+ errorsRef.current = [];
+ lastDepSnapshotRef.current = '';
+ dynamicDepsRef.current = {};
+ setAvailableViews([]);
+ setSelectedViews(new Set());
+ setLoadingViews(false);
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ }
+ }, [show]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const fetchLayers = async () => {
+ setLoading(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 {
+ setLoading(false);
+ }
+ };
+
+ const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
+ const schema = sanitizeSchema(rawSchema);
+ setRuntimeSchema(schema);
+ setRuntimeUiSchema(buildUiSchema(schema));
+ dynamicDepsRef.current = getDynamicDependencies(rawSchema);
+ }, []);
+
+ const fetchRuntimeSchema = useCallback(
+ async (uuid: string, currentRuntimeData?: Record<string, unknown>) => {
+ const isInitialFetch = !currentRuntimeData;
+ if (isInitialFetch) setLoading(true);
+ else setRefreshingSchema(true);
+ try {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
+ jsonPayload: currentRuntimeData
+ ? { runtime_data: currentRuntimeData }
+ : {},
+ });
+ const schema = json.result;
+ if (
+ isInitialFetch &&
+ (!schema ||
+ !schema.properties ||
+ Object.keys(schema.properties).length === 0)
+ ) {
+ // No runtime config needed — skip to step 3
+ fetchViews(uuid, {});
+ } else if (isInitialFetch) {
+ applyRuntimeSchema(schema);
+ setStep('configure');
+ } else {
+ applyRuntimeSchema(schema);
+ }
+ } catch {
+ if (isInitialFetch) {
+ addDangerToast(
+ t('An error occurred while fetching the runtime schema'),
+ );
+ }
+ } finally {
+ if (isInitialFetch) setLoading(false);
+ else setRefreshingSchema(false);
+ }
+ },
+ [addDangerToast, applyRuntimeSchema], // eslint-disable-line react-hooks/exhaustive-deps
+ );
+
+ const fetchViews = useCallback(
+ async (uuid: string, rData: Record<string, unknown>) => {
+ setLoadingViews(true);
+ setStep('select');
+ try {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/semantic_layer/${uuid}/views`,
+ jsonPayload: { runtime_data: rData },
+ });
+ const views: AvailableView[] = json.result ?? [];
+ setAvailableViews(views);
+ // Pre-select views that are already added (disabled anyway)
+ setSelectedViews(
+ new Set(views.filter(v => v.already_added).map(v => v.name)),
+ );
+ } catch {
+ addDangerToast(t('An error occurred while fetching available views'));
+ } finally {
+ setLoadingViews(false);
+ }
+ },
+ [addDangerToast],
+ );
+
+ const maybeRefreshRuntimeSchema = useCallback(
+ (data: Record<string, unknown>) => {
+ if (!selectedLayerUuid) return;
+
+ const dynamicDeps = dynamicDepsRef.current;
+ if (Object.keys(dynamicDeps).length === 0) return;
+
+ const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
+ areDependenciesSatisfied(deps, data),
+ );
+ if (!hasSatisfiedDeps) return;
+
+ const snapshot = serializeDependencyValues(dynamicDeps, data);
+ if (snapshot === lastDepSnapshotRef.current) return;
+ lastDepSnapshotRef.current = snapshot;
+
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = setTimeout(() => {
+ fetchRuntimeSchema(selectedLayerUuid, data);
+ }, SCHEMA_REFRESH_DEBOUNCE_MS);
+ },
+ [selectedLayerUuid, fetchRuntimeSchema],
+ );
+
+ const handleRuntimeFormChange = useCallback(
+ ({
+ data,
+ errors,
+ }: {
+ data: Record<string, unknown>;
+ errors?: ErrorObject[];
+ }) => {
+ setRuntimeData(data);
+ errorsRef.current = errors ?? [];
+ setHasRuntimeErrors(errorsRef.current.length > 0);
+ maybeRefreshRuntimeSchema(data);
+ },
+ [maybeRefreshRuntimeSchema],
+ );
+
+ const handleToggleView = (viewName: string, checked: boolean) => {
+ setSelectedViews(prev => {
+ const next = new Set(prev);
+ if (checked) {
+ next.add(viewName);
+ } else {
+ next.delete(viewName);
+ }
+ return next;
+ });
+ };
+
+ const newViewCount = availableViews.filter(
+ v => selectedViews.has(v.name) && !v.already_added,
+ ).length;
+
+ const handleAddViews = async () => {
+ if (!selectedLayerUuid) return;
+ setSaving(true);
+ try {
+ const viewsToCreate = availableViews
+ .filter(v => selectedViews.has(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);
+ }
+ };
+
+ const handleSave = () => {
+ if (step === 'layer' && selectedLayerUuid) {
+ fetchRuntimeSchema(selectedLayerUuid);
+ } else if (step === 'configure' && selectedLayerUuid) {
+ fetchViews(selectedLayerUuid, runtimeData);
+ } else if (step === 'select') {
+ handleAddViews();
+ }
+ };
+
+ const handleBack = () => {
+ if (step === 'configure') {
+ setStep('layer');
+ setRuntimeSchema(null);
+ setRuntimeUiSchema(undefined);
+ setRuntimeData({});
+ setHasRuntimeErrors(false);
+ errorsRef.current = [];
+ lastDepSnapshotRef.current = '';
+ dynamicDepsRef.current = {};
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ } else if (step === 'select') {
+ // Go back to configure if we had a runtime schema, otherwise to layer
+ if (runtimeSchema) {
+ setStep('configure');
+ } else {
+ setStep('layer');
+ }
+ setAvailableViews([]);
+ setSelectedViews(new Set());
+ }
+ };
+
+ const title =
+ step === 'layer'
+ ? t('Add Semantic View')
+ : step === 'configure'
+ ? t('Configure')
+ : t('Select Views');
+
+ const saveText =
+ step === 'select' ? t('Add %s view(s)', newViewCount) : t('Next');
+
+ const saveDisabled =
+ step === 'layer'
+ ? !selectedLayerUuid
+ : step === 'configure'
+ ? hasRuntimeErrors
+ : step === 'select'
+ ? newViewCount === 0 || saving
+ : false;
+
+ const modalWidth =
+ step === 'configure' ? MODAL_MEDIUM_WIDTH : MODAL_STANDARD_WIDTH;
+
+ return (
+ <StandardModal
+ show={show}
+ onHide={onHide}
+ onSave={handleSave}
+ title={title}
+ icon={<Icons.PlusOutlined />}
+ width={modalWidth}
+ saveDisabled={saveDisabled}
+ saveText={saveText}
+ saveLoading={saving}
+ contentLoading={loading}
+ >
+ {step === 'layer' && (
+ <ModalContent>
+ <ModalFormField label={t('Semantic Layer')}>
+ <Select
+ ariaLabel={t('Semantic layer')}
+ placeholder={t('Select a semantic layer')}
+ value={selectedLayerUuid}
+ onChange={value => setSelectedLayerUuid(value as string)}
+ options={layers.map(l => ({
+ value: l.uuid,
+ label: l.name,
+ }))}
+ getPopupContainer={() => document.body}
+ dropdownAlign={{
+ points: ['tl', 'bl'],
+ offset: [0, 4],
+ overflow: { adjustX: 0, adjustY: 1 },
+ }}
+ />
+ </ModalFormField>
+ </ModalContent>
+ )}
+
+ {step === 'configure' && (
+ <ModalContent>
+ <BackLink type="button" onClick={handleBack}>
+ <Icons.CaretLeftOutlined iconSize="s" />
+ {t('Back')}
+ </BackLink>
+ {runtimeSchema && (
+ <JsonForms
+ schema={runtimeSchema}
+ uischema={runtimeUiSchema}
+ data={runtimeData}
+ renderers={renderers}
+ cells={cellRegistryEntries}
+ config={{ refreshingSchema, formData: runtimeData }}
+ validationMode="ValidateAndHide"
+ onChange={handleRuntimeFormChange}
+ />
+ )}
+ </ModalContent>
+ )}
+
+ {step === 'select' && (
+ <ModalContent>
+ <BackLink type="button" onClick={handleBack}>
+ <Icons.CaretLeftOutlined iconSize="s" />
+ {t('Back')}
+ </BackLink>
+ {loadingViews ? (
+ <LoadingContainer>
+ <Spin />
+ </LoadingContainer>
+ ) : (
+ <ViewList>
+ {availableViews.map(view => (
+ <Checkbox
+ key={view.name}
+ checked={selectedViews.has(view.name)}
+ disabled={view.already_added}
+ onChange={e => handleToggleView(view.name, e.target.checked)}
+ >
+ {view.name}
+ {view.already_added && <span> ({t('Already added')})</span>}
+ </Checkbox>
+ ))}
+ {availableViews.length === 0 && !loadingViews && (
+ <span>{t('No views available')}</span>
+ )}
+ </ViewList>
+ )}
+ </ModalContent>
+ )}
+ </StandardModal>
+ );
+}
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index fabd1379a4b..78eb5726ac8 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -37,9 +37,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,
@@ -75,6 +77,7 @@ 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 { useSelector } from 'react-redux';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -265,6 +268,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
null,
);
+ const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
+ useState(false);
const [importingDataset, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
@@ -527,25 +532,43 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
Cell: ({ row: { original } }: any) => {
const isSemanticView = original.source_type === 'semantic_layer';
- // 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>
);
}
@@ -852,14 +875,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: t('Dataset'),
+ onClick: () => {
+ history.push('/dataset/add/');
+ },
+ buttonStyle: 'primary',
+ });
+ }
}
menuData.buttons = buttonArr;
@@ -893,15 +960,56 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
};
- const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
+ const handleSemanticViewDelete = ({ id, table_name: tableName }: Dataset) => {
SupersetClient.delete({
- endpoint: `/api/v1/dataset/?q=${rison.encode(
- datasetsToDelete.map(({ id }) => id),
- )}`,
+ endpoint: `/api/v1/semantic_view/${id}`,
}).then(
- ({ json = {} }) => {
+ () => {
refreshData();
- addSuccessToast(json.message);
+ addSuccessToast(t('Deleted: %s', tableName));
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('There was an issue deleting %s: %s', tableName, errMsg),
+ ),
+ ),
+ );
+ };
+
+ const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
+ 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(
+ ...semanticViews.map(sv =>
+ SupersetClient.delete({
+ endpoint: `/api/v1/semantic_view/${sv.id}`,
+ }),
+ ),
+ );
+ }
+
+ Promise.all(promises).then(
+ () => {
+ refreshData();
+ addSuccessToast(t('Deleted %s item(s)', datasetsToDelete.length));
},
createErrorHandler(errMsg =>
addDangerToast(
@@ -1073,6 +1181,13 @@ 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(
diff --git a/superset/commands/semantic_layer/create.py b/superset/commands/semantic_layer/create.py
index 8729f75a13b..7cb78df8a5f 100644
--- a/superset/commands/semantic_layer/create.py
+++ b/superset/commands/semantic_layer/create.py
@@ -16,7 +16,6 @@
# under the License.
from __future__ import annotations
-import json
import logging
from functools import partial
from typing import Any
@@ -28,9 +27,12 @@ from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerInvalidError,
+ SemanticLayerNotFoundError,
+ SemanticViewCreateFailedError,
)
-from superset.daos.semantic_layer import SemanticLayerDAO
+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__)
@@ -67,3 +69,28 @@ class CreateSemanticLayerCommand(BaseCommand):
# 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 = self._properties.get("semantic_layer_uuid")
+ if not SemanticLayerDAO.find_by_uuid(layer_uuid):
+ raise SemanticLayerNotFoundError()
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index 677126221a8..1e9088476bc 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -25,9 +25,11 @@ from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerDeleteFailedError,
SemanticLayerNotFoundError,
+ SemanticViewDeleteFailedError,
+ SemanticViewNotFoundError,
)
-from superset.daos.semantic_layer import SemanticLayerDAO
-from superset.semantic_layers.models import SemanticLayer
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
+from superset.semantic_layers.models import SemanticLayer, SemanticView
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -54,3 +56,26 @@ class DeleteSemanticLayerCommand(BaseCommand):
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)
+ if not self._model:
+ raise SemanticViewNotFoundError()
diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py
index 1b82a65b841..ec1a4ac838d 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -66,3 +66,11 @@ class SemanticLayerUpdateFailedError(UpdateFailedError):
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.")
diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py
index 3271cb76306..d837102e265 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -22,6 +22,7 @@ from __future__ import annotations
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(BaseDAO[SemanticLayer]):
@@ -123,18 +124,37 @@ class SemanticViewDAO(BaseDAO[SemanticView]):
)
@staticmethod
- def validate_uniqueness(name: str, layer_uuid: str) -> bool:
+ def validate_uniqueness(
+ name: str,
+ layer_uuid: str,
+ configuration: dict | None = None,
+ ) -> bool:
"""
Validate that view name is unique within semantic layer.
+ When *configuration* is provided, uniqueness is scoped to the
+ ``(name, layer_uuid, configuration)`` triple so the same view name
+ can exist with different runtime configurations.
+
:param name: View name
:param layer_uuid: UUID of the semantic layer
- :return: True if name is unique within layer, False otherwise
+ :param configuration: Optional configuration dict for scoped uniqueness
+ :return: True if name is unique within layer (and config), False otherwise
"""
query = db.session.query(SemanticView).filter(
SemanticView.name == name,
SemanticView.semantic_layer_uuid == layer_uuid,
)
+ if configuration is not None:
+ config_str = json.dumps(configuration, sort_keys=True)
+ # Compare serialized configuration
+ for view in query.all():
+ existing_config = view.configuration
+ if isinstance(existing_config, str):
+ existing_config = json.loads(existing_config)
+ if json.dumps(existing_config or {}, sort_keys=True) == config_str:
+ return False
+ return True
return not db.session.query(query.exists()).scalar()
@staticmethod
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index aeaa9764a33..10e99bb4837 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -16,7 +16,6 @@
# under the License.
from __future__ import annotations
-import json
import logging
from typing import Any
@@ -28,14 +27,22 @@ from marshmallow import ValidationError
from pydantic import ValidationError as PydanticValidationError
from superset import db, event_logger, is_feature_enabled
-from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
-from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
+from superset.commands.semantic_layer.create import (
+ CreateSemanticLayerCommand,
+ CreateSemanticViewCommand,
+)
+from superset.commands.semantic_layer.delete import (
+ DeleteSemanticLayerCommand,
+ DeleteSemanticViewCommand,
+)
from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerDeleteFailedError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
+ SemanticViewCreateFailedError,
+ SemanticViewDeleteFailedError,
SemanticViewForbiddenError,
SemanticViewInvalidError,
SemanticViewNotFoundError,
@@ -46,16 +53,18 @@ from superset.commands.semantic_layer.update import (
UpdateSemanticViewCommand,
)
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
-from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
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,
@@ -161,10 +170,89 @@ 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"}
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
+ return self.response(201, result=result)
+
@expose("/<pk>", methods=("PUT",))
@protect()
@statsd_metrics
@@ -238,6 +326,46 @@ 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 SemanticViewDeleteFailedError as ex:
+ logger.error(
+ "Error deleting semantic view: %s",
+ str(ex),
+ exc_info=True,
+ )
+ return self.response_422(message=str(ex))
+
class SemanticLayerRestApi(BaseSupersetApi):
resource_name = "semantic_layer"
@@ -369,6 +497,67 @@ class SemanticLayerRestApi(BaseSupersetApi):
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
+ return self.response_400(message=str(ex))
+
+ # Check which views already exist with the same runtime config
+ existing = SemanticViewDAO.find_by_semantic_layer(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
@@ -529,8 +718,7 @@ class SemanticLayerRestApi(BaseSupersetApi):
@statsd_metrics
@rison(get_list_schema)
@event_logger.log_this_with_context(
- action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
- ".connections",
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.connections",
log_to_statsd=False,
)
def connections(self, **kwargs: Any) -> FlaskResponse:
@@ -560,6 +748,34 @@ class SemanticLayerRestApi(BaseSupersetApi):
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:
@@ -567,11 +783,35 @@ class SemanticLayerRestApi(BaseSupersetApi):
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
- if not is_feature_enabled("SEMANTIC_LAYERS"):
- source_type = "database"
+ @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()]
- reverse = order_direction == "desc"
+ 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],
@@ -593,45 +833,7 @@ class SemanticLayerRestApi(BaseSupersetApi):
"changed_on_delta_humanized": _sort_key_changed_on,
"database_name": _sort_key_name,
}
- sort_key = sort_key_map.get(order_column, _sort_key_changed_on)
-
- # Fetch databases (lightweight: only loads ORM objects, no eager joins)
- 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()]
-
- # Fetch semantic layers
- 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()]
-
- # Merge, sort, count, paginate
- all_items: list[tuple[str, Any]] = db_items + sl_items # type: ignore
- all_items.sort(key=sort_key, reverse=reverse) # type: ignore
- total_count = len(all_items)
-
- start = page * page_size
- page_items = all_items[start : start + page_size]
-
- # Serialize
- result = []
- for item_type, obj in page_items:
- if item_type == "database":
- result.append(self._serialize_database(obj))
- else:
- result.append(self._serialize_semantic_layer(obj))
-
- return self.response(200, count=total_count, result=result)
+ return sort_key_map.get(order_column, _sort_key_changed_on)
@staticmethod
def _serialize_database(obj: Database) -> dict[str, Any]:
diff --git a/superset/semantic_layers/schemas.py b/superset/semantic_layers/schemas.py
index d10e0fd28fb..c0d835a7354 100644
--- a/superset/semantic_layers/schemas.py
+++ b/superset/semantic_layers/schemas.py
@@ -35,3 +35,11 @@ class SemanticLayerPutSchema(Schema):
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)