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 31bc8f86e310214d4236e9d82c6ac5ef020632a6 Author: Beto Dealmeida <[email protected]> AuthorDate: Tue Feb 17 11:50:06 2026 -0500 feat: CRUD for adding/deleting semantic views --- .../features/semanticLayers/SemanticLayerModal.tsx | 253 +---------- .../features/semanticLayers/jsonFormsHelpers.tsx | 257 +++++++++++ .../semanticViews/AddSemanticViewModal.tsx | 486 +++++++++++++++++++++ superset-frontend/src/pages/DatasetList/index.tsx | 178 ++++++-- superset/commands/semantic_layer/create.py | 29 +- superset/commands/semantic_layer/delete.py | 29 +- superset/commands/semantic_layer/exceptions.py | 8 + superset/daos/semantic_layer.py | 25 +- superset/semantic_layers/api.py | 199 ++++++++- superset/semantic_layers/schemas.py | 8 + superset/static/service-worker.js | 2 +- 11 files changed, 1196 insertions(+), 278 deletions(-) diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx index 37694a9e9a6..25bf810ca48 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. 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..6859dcab8eb --- /dev/null +++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx @@ -0,0 +1,486 @@ +/** + * 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 ceafdd03a42..65cb31f74a7 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -35,9 +35,11 @@ import { import { ColumnObject } from 'src/features/datasets/types'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { + Button, ConfirmStatusChange, CertifiedBadge, DeleteModal, + Dropdown, Tooltip, InfoTooltip, DatasetTypeLabel, @@ -72,6 +74,7 @@ import { } from 'src/features/datasets/constants'; import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal'; 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'; @@ -262,6 +265,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); @@ -520,25 +525,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> ); } @@ -834,14 +857,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; @@ -875,15 +942,61 @@ 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(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(json.message); + addSuccessToast( + t('Deleted %s item(s)', datasetsToDelete.length), + ); }, createErrorHandler(errMsg => addDangerToast( @@ -1055,6 +1168,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 250476d210f..888d6f36dfd 100644 --- a/superset/commands/semantic_layer/create.py +++ b/superset/commands/semantic_layer/create.py @@ -28,8 +28,10 @@ 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.decorators import on_error, transaction @@ -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 13ce777567a..0cf75dd6d45 100644 --- a/superset/daos/semantic_layer.py +++ b/superset/daos/semantic_layer.py @@ -19,6 +19,8 @@ from __future__ import annotations +import json + from superset.daos.base import BaseDAO from superset.extensions import db from superset.semantic_layers.models import SemanticLayer, SemanticView @@ -114,18 +116,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 bed9dc996a5..c93e8039df6 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -28,14 +28,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,13 +54,14 @@ 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 @@ -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" @@ -368,6 +496,69 @@ 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 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) diff --git a/superset/static/service-worker.js b/superset/static/service-worker.js index 88dce3da348..74fc10a3285 100644 --- a/superset/static/service-worker.js +++ b/superset/static/service-worker.js @@ -170,7 +170,7 @@ eval("{/**\n * Licensed to the Apache Software Foundation (ASF) under one\n * or /******/ /******/ /* webpack/runtime/getFullHash */ /******/ (() => { -/******/ __webpack_require__.h = () => ("9e4777e49256d5920929") +/******/ __webpack_require__.h = () => ("7be3989f0fd5f505f982") /******/ })(); /******/ /******/ /* webpack/runtime/harmony module decorator */
