mirror of
https://github.com/apache/superset.git
synced 2026-07-18 20:55:47 +00:00
Compare commits
7 Commits
backup/sem
...
backup/sem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f400655644 | ||
|
|
d0a190f349 | ||
|
|
e2b3ab22a8 | ||
|
|
de80b50678 | ||
|
|
85fb1e772c | ||
|
|
3b2dfec405 | ||
|
|
6b6d23e4c3 |
@@ -20,27 +20,12 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { Input, 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,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether all dependency values are filled (non-empty).
|
||||
* Handles nested objects (like auth) by checking they have at least one key.
|
||||
*/
|
||||
function areDependenciesSatisfied(
|
||||
dependencies: string[],
|
||||
data: Record<string, unknown>,
|
||||
): boolean {
|
||||
return dependencies.every(dep => {
|
||||
const value = data[dep];
|
||||
if (value === null || value === undefined || value === '') return false;
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for fields marked `x-dynamic` in the JSON Schema.
|
||||
* Shows a loading spinner inside the input while the schema is being
|
||||
* refreshed with dynamic values from the backend.
|
||||
*/
|
||||
function DynamicFieldControl(props: ControlProps) {
|
||||
const { refreshingSchema, formData: cfgData } = props.config ?? {};
|
||||
const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
|
||||
const refreshing =
|
||||
refreshingSchema &&
|
||||
Array.isArray(deps) &&
|
||||
areDependenciesSatisfied(
|
||||
deps as string[],
|
||||
(cfgData as Record<string, unknown>) ?? {},
|
||||
);
|
||||
|
||||
if (!refreshing) {
|
||||
return TextControl(props);
|
||||
}
|
||||
|
||||
const uischema = {
|
||||
...props.uischema,
|
||||
options: {
|
||||
...props.uischema.options,
|
||||
placeholderText: t('Loading...'),
|
||||
inputProps: { suffix: <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 } as JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a JSON Forms UI schema from a JSON Schema, using the first
|
||||
* `examples` entry as placeholder text for each string property.
|
||||
*/
|
||||
function buildUiSchema(schema: JsonSchema): UISchemaElement | undefined {
|
||||
if (!schema.properties) return undefined;
|
||||
|
||||
// Use explicit property order from backend if available,
|
||||
// otherwise fall back to the JSON object key order
|
||||
const propertyOrder: string[] =
|
||||
((schema as Record<string, unknown>)['x-propertyOrder'] as string[]) ??
|
||||
Object.keys(schema.properties);
|
||||
|
||||
const elements = propertyOrder
|
||||
.filter(key => key in (schema.properties ?? {}))
|
||||
.map(key => {
|
||||
const prop = schema.properties![key];
|
||||
const control: Record<string, unknown> = {
|
||||
type: 'Control',
|
||||
scope: `#/properties/${key}`,
|
||||
};
|
||||
if (typeof prop === 'object' && prop !== null) {
|
||||
const options: Record<string, unknown> = {};
|
||||
if (
|
||||
'examples' in prop &&
|
||||
Array.isArray(prop.examples) &&
|
||||
prop.examples.length > 0
|
||||
) {
|
||||
options.placeholderText = String(prop.examples[0]);
|
||||
}
|
||||
if ('description' in prop && typeof prop.description === 'string') {
|
||||
options.tooltip = prop.description;
|
||||
}
|
||||
if (Object.keys(options).length > 0) {
|
||||
control.options = options;
|
||||
}
|
||||
}
|
||||
return control;
|
||||
});
|
||||
return { type: 'VerticalLayout', elements } as UISchemaElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts dynamic field dependency mappings from the schema.
|
||||
* Returns a map of field name → list of dependency field names.
|
||||
*/
|
||||
function getDynamicDependencies(schema: JsonSchema): Record<string, string[]> {
|
||||
const deps: Record<string, string[]> = {};
|
||||
if (!schema.properties) return deps;
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
if (
|
||||
typeof prop === 'object' &&
|
||||
prop !== null &&
|
||||
'x-dynamic' in prop &&
|
||||
'x-dependsOn' in prop &&
|
||||
Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
|
||||
) {
|
||||
deps[key] = (prop as Record<string, unknown>)['x-dependsOn'] as string[];
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the dependency values for a set of fields into a stable string
|
||||
* for comparison, so we only re-fetch when dependency values actually change.
|
||||
*/
|
||||
function serializeDependencyValues(
|
||||
dynamicDeps: Record<string, string[]>,
|
||||
data: Record<string, unknown>,
|
||||
): string {
|
||||
const allDepKeys = new Set<string>();
|
||||
for (const deps of Object.values(dynamicDeps)) {
|
||||
for (const dep of deps) {
|
||||
allDepKeys.add(dep);
|
||||
}
|
||||
}
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const key of [...allDepKeys].sort()) {
|
||||
snapshot[key] = data[key];
|
||||
}
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Spin } from 'antd';
|
||||
import { withJsonFormsControlProps } from '@jsonforms/react';
|
||||
import type {
|
||||
JsonSchema,
|
||||
UISchemaElement,
|
||||
ControlProps,
|
||||
} from '@jsonforms/core';
|
||||
import {
|
||||
rankWith,
|
||||
and,
|
||||
isStringControl,
|
||||
formatIs,
|
||||
schemaMatches,
|
||||
} from '@jsonforms/core';
|
||||
import {
|
||||
rendererRegistryEntries,
|
||||
TextControl,
|
||||
} from '@great-expectations/jsonforms-antd-renderers';
|
||||
|
||||
export const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Custom renderer that renders `Input.Password` for fields with
|
||||
* `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
|
||||
*/
|
||||
function PasswordControl(props: ControlProps) {
|
||||
const uischema = {
|
||||
...props.uischema,
|
||||
options: { ...props.uischema.options, type: 'password' },
|
||||
};
|
||||
return TextControl({ ...props, uischema });
|
||||
}
|
||||
const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
|
||||
const passwordEntry = {
|
||||
tester: rankWith(3, and(isStringControl, formatIs('password'))),
|
||||
renderer: PasswordRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderer for `const` properties (e.g. Pydantic discriminator fields).
|
||||
* Renders nothing visually but ensures the const value is set in form data,
|
||||
* so discriminated unions resolve correctly on the backend.
|
||||
*/
|
||||
function ConstControl({ data, handleChange, path, schema }: ControlProps) {
|
||||
const constValue = (schema as Record<string, unknown>).const;
|
||||
useEffect(() => {
|
||||
if (constValue !== undefined && data !== constValue) {
|
||||
handleChange(path, constValue);
|
||||
}
|
||||
}, [constValue, data, handleChange, path]);
|
||||
return null;
|
||||
}
|
||||
const ConstRenderer = withJsonFormsControlProps(ConstControl);
|
||||
const constEntry = {
|
||||
tester: rankWith(
|
||||
10,
|
||||
schemaMatches(
|
||||
s =>
|
||||
s !== undefined &&
|
||||
'const' in s &&
|
||||
!(s as Record<string, unknown>).readOnly,
|
||||
),
|
||||
),
|
||||
renderer: ConstRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderer for read-only fields (e.g. a fixed database that the admin locked).
|
||||
* Renders a disabled input showing the current value. Also ensures the default
|
||||
* value is injected into form data (like ConstControl does for hidden fields).
|
||||
*/
|
||||
function ReadOnlyControl({
|
||||
data,
|
||||
handleChange,
|
||||
path,
|
||||
schema,
|
||||
...rest
|
||||
}: ControlProps) {
|
||||
const defaultValue =
|
||||
(schema as Record<string, unknown>).const ??
|
||||
(schema as Record<string, unknown>).default;
|
||||
useEffect(() => {
|
||||
if (defaultValue !== undefined && data !== defaultValue) {
|
||||
handleChange(path, defaultValue);
|
||||
}
|
||||
}, [defaultValue, data, handleChange, path]);
|
||||
|
||||
return TextControl({ ...rest, data, handleChange, path, schema, enabled: false });
|
||||
}
|
||||
const ReadOnlyRenderer = withJsonFormsControlProps(ReadOnlyControl);
|
||||
const readOnlyEntry = {
|
||||
tester: rankWith(
|
||||
11,
|
||||
schemaMatches(
|
||||
s =>
|
||||
s !== undefined && (s as Record<string, unknown>).readOnly === true,
|
||||
),
|
||||
),
|
||||
renderer: ReadOnlyRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether all dependency values are filled (non-empty).
|
||||
* Handles nested objects (like auth) by checking they have at least one key.
|
||||
*/
|
||||
export function areDependenciesSatisfied(
|
||||
dependencies: string[],
|
||||
data: Record<string, unknown>,
|
||||
): boolean {
|
||||
return dependencies.every(dep => {
|
||||
const value = data[dep];
|
||||
if (value === null || value === undefined || value === '') return false;
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for fields marked `x-dynamic` in the JSON Schema.
|
||||
* Shows a loading spinner inside the input while the schema is being
|
||||
* refreshed with dynamic values from the backend.
|
||||
*/
|
||||
function DynamicFieldControl(props: ControlProps) {
|
||||
const { refreshingSchema, formData: cfgData } = props.config ?? {};
|
||||
const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
|
||||
const refreshing =
|
||||
refreshingSchema &&
|
||||
Array.isArray(deps) &&
|
||||
areDependenciesSatisfied(deps as string[], (cfgData as Record<string, unknown>) ?? {});
|
||||
|
||||
if (!refreshing) {
|
||||
return TextControl(props);
|
||||
}
|
||||
|
||||
const uischema = {
|
||||
...props.uischema,
|
||||
options: {
|
||||
...props.uischema.options,
|
||||
placeholderText: t('Loading...'),
|
||||
inputProps: { suffix: <Spin size="small" /> },
|
||||
},
|
||||
};
|
||||
return TextControl({ ...props, uischema, enabled: false });
|
||||
}
|
||||
const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
|
||||
const dynamicFieldEntry = {
|
||||
tester: rankWith(
|
||||
3,
|
||||
and(
|
||||
isStringControl,
|
||||
schemaMatches(
|
||||
s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
|
||||
),
|
||||
),
|
||||
),
|
||||
renderer: DynamicFieldRenderer,
|
||||
};
|
||||
|
||||
export const renderers = [
|
||||
...rendererRegistryEntries,
|
||||
passwordEntry,
|
||||
constEntry,
|
||||
readOnlyEntry,
|
||||
dynamicFieldEntry,
|
||||
];
|
||||
|
||||
/**
|
||||
* Removes empty `enum` arrays from schema properties. The JSON Schema spec
|
||||
* requires `enum` to have at least one item, and AJV rejects empty arrays.
|
||||
* Fields with empty enums are rendered as plain text inputs instead.
|
||||
*/
|
||||
export function sanitizeSchema(schema: JsonSchema): JsonSchema {
|
||||
if (!schema.properties) return schema;
|
||||
const properties: Record<string, JsonSchema> = {};
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
if (
|
||||
typeof prop === 'object' &&
|
||||
prop !== null &&
|
||||
'enum' in prop &&
|
||||
Array.isArray(prop.enum) &&
|
||||
prop.enum.length === 0
|
||||
) {
|
||||
const { enum: _empty, ...rest } = prop;
|
||||
properties[key] = rest;
|
||||
} else {
|
||||
properties[key] = prop as JsonSchema;
|
||||
}
|
||||
}
|
||||
return { ...schema, properties } as JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a JSON Forms UI schema from a JSON Schema, using the first
|
||||
* `examples` entry as placeholder text for each string property.
|
||||
*/
|
||||
export function buildUiSchema(
|
||||
schema: JsonSchema,
|
||||
): UISchemaElement | undefined {
|
||||
if (!schema.properties) return undefined;
|
||||
|
||||
// Use explicit property order from backend if available,
|
||||
// otherwise fall back to the JSON object key order
|
||||
const propertyOrder: string[] =
|
||||
(schema as Record<string, unknown>)['x-propertyOrder'] as string[] ??
|
||||
Object.keys(schema.properties);
|
||||
|
||||
const elements = propertyOrder
|
||||
.filter(key => key in (schema.properties ?? {}))
|
||||
.map(key => {
|
||||
const prop = schema.properties![key];
|
||||
const control: Record<string, unknown> = {
|
||||
type: 'Control',
|
||||
scope: `#/properties/${key}`,
|
||||
};
|
||||
if (typeof prop === 'object' && prop !== null) {
|
||||
const options: Record<string, unknown> = {};
|
||||
if (
|
||||
'examples' in prop &&
|
||||
Array.isArray(prop.examples) &&
|
||||
prop.examples.length > 0
|
||||
) {
|
||||
options.placeholderText = String(prop.examples[0]);
|
||||
}
|
||||
if ('description' in prop && typeof prop.description === 'string') {
|
||||
options.tooltip = prop.description;
|
||||
}
|
||||
if (Object.keys(options).length > 0) {
|
||||
control.options = options;
|
||||
}
|
||||
}
|
||||
return control;
|
||||
});
|
||||
return { type: 'VerticalLayout', elements } as UISchemaElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts dynamic field dependency mappings from the schema.
|
||||
* Returns a map of field name -> list of dependency field names.
|
||||
*/
|
||||
export function getDynamicDependencies(
|
||||
schema: JsonSchema,
|
||||
): Record<string, string[]> {
|
||||
const deps: Record<string, string[]> = {};
|
||||
if (!schema.properties) return deps;
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
if (
|
||||
typeof prop === 'object' &&
|
||||
prop !== null &&
|
||||
'x-dynamic' in prop &&
|
||||
'x-dependsOn' in prop &&
|
||||
Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
|
||||
) {
|
||||
deps[key] = (prop as Record<string, unknown>)[
|
||||
'x-dependsOn'
|
||||
] as string[];
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the dependency values for a set of fields into a stable string
|
||||
* for comparison, so we only re-fetch when dependency values actually change.
|
||||
*/
|
||||
export function serializeDependencyValues(
|
||||
dynamicDeps: Record<string, string[]>,
|
||||
data: Record<string, unknown>,
|
||||
): string {
|
||||
const allDepKeys = new Set<string>();
|
||||
for (const deps of Object.values(dynamicDeps)) {
|
||||
for (const dep of deps) {
|
||||
allDepKeys.add(dep);
|
||||
}
|
||||
}
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const key of [...allDepKeys].sort()) {
|
||||
snapshot[key] = data[key];
|
||||
}
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { Spin } from 'antd';
|
||||
import { Select } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { JsonForms } from '@jsonforms/react';
|
||||
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
|
||||
import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
|
||||
import type { ErrorObject } from 'ajv';
|
||||
import {
|
||||
StandardModal,
|
||||
ModalFormField,
|
||||
MODAL_STANDARD_WIDTH,
|
||||
} from 'src/components/Modal';
|
||||
import {
|
||||
renderers,
|
||||
sanitizeSchema,
|
||||
buildUiSchema,
|
||||
getDynamicDependencies,
|
||||
areDependenciesSatisfied,
|
||||
serializeDependencyValues,
|
||||
SCHEMA_REFRESH_DEBOUNCE_MS,
|
||||
} from 'src/features/semanticLayers/jsonFormsHelpers';
|
||||
|
||||
interface SemanticLayerOption {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AvailableView {
|
||||
name: string;
|
||||
already_added: boolean;
|
||||
}
|
||||
|
||||
const ModalContent = styled.div`
|
||||
padding: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const SectionLabel = styled.div`
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
font-size: ${({ theme }) => theme.fontSize}px;
|
||||
margin-top: ${({ theme }) => theme.sizeUnit}px;
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
const VerticalFormFields = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
|
||||
/* The antd renderer's VerticalLayout creates its own <Form> —
|
||||
force flex-column so gap controls spacing between fields */
|
||||
&& form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
}
|
||||
|
||||
/* Reset antd default margins so gap controls all spacing */
|
||||
&& .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Override ant-form-item-horizontal: stack label above control */
|
||||
&& .ant-form-item-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&& .ant-form-item-label {
|
||||
text-align: left;
|
||||
max-width: 100%;
|
||||
flex: none;
|
||||
padding-bottom: ${({ theme }) => theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
&& .ant-form-item-control {
|
||||
max-width: 100%;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
&& .ant-form-item-label > label {
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
font-size: ${({ theme }) => theme.fontSize}px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface AddSemanticViewModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSuccess: () => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
export default function AddSemanticViewModal({
|
||||
show,
|
||||
onHide,
|
||||
onSuccess,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}: AddSemanticViewModalProps) {
|
||||
// --- Layer ---
|
||||
const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
|
||||
const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [loadingLayers, setLoadingLayers] = useState(false);
|
||||
|
||||
// --- Runtime config ---
|
||||
const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
|
||||
const [runtimeUiSchema, setRuntimeUiSchema] = useState<
|
||||
UISchemaElement | undefined
|
||||
>();
|
||||
const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
|
||||
const [loadingRuntime, setLoadingRuntime] = useState(false);
|
||||
const [refreshingSchema, setRefreshingSchema] = useState(false);
|
||||
const errorsRef = useRef<ErrorObject[]>([]);
|
||||
const dynamicDepsRef = useRef<Record<string, string[]>>({});
|
||||
const lastDepSnapshotRef = useRef('');
|
||||
const schemaTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// --- Views ---
|
||||
const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
|
||||
const [selectedViewNames, setSelectedViewNames] = useState<string[]>([]);
|
||||
const [loadingViews, setLoadingViews] = useState(false);
|
||||
const viewsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastViewsKeyRef = useRef('');
|
||||
|
||||
// --- Misc ---
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fetchGenRef = useRef(0);
|
||||
|
||||
// =========================================================================
|
||||
// Fetch helpers
|
||||
// =========================================================================
|
||||
|
||||
const fetchLayers = async () => {
|
||||
setLoadingLayers(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: '/api/v1/semantic_layer/',
|
||||
});
|
||||
setLayers(
|
||||
(json.result ?? []).map((l: { uuid: string; name: string }) => ({
|
||||
uuid: l.uuid,
|
||||
name: l.name,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
addDangerToast(t('An error occurred while fetching semantic layers'));
|
||||
} finally {
|
||||
setLoadingLayers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchViews = useCallback(
|
||||
async (uuid: string, rData: Record<string, unknown>, gen: number) => {
|
||||
setLoadingViews(true);
|
||||
setAvailableViews([]);
|
||||
setSelectedViewNames([]);
|
||||
try {
|
||||
const { json } = await SupersetClient.post({
|
||||
endpoint: `/api/v1/semantic_layer/${uuid}/views`,
|
||||
jsonPayload: { runtime_data: rData },
|
||||
});
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
setAvailableViews(json.result ?? []);
|
||||
} catch {
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
addDangerToast(t('An error occurred while fetching available views'));
|
||||
} finally {
|
||||
if (gen === fetchGenRef.current) setLoadingViews(false);
|
||||
}
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
|
||||
const schema = sanitizeSchema(rawSchema);
|
||||
setRuntimeSchema(schema);
|
||||
setRuntimeUiSchema(buildUiSchema(schema));
|
||||
dynamicDepsRef.current = getDynamicDependencies(rawSchema);
|
||||
}, []);
|
||||
|
||||
const scheduleFetchViews = useCallback(
|
||||
(uuid: string, data: Record<string, unknown>) => {
|
||||
const key = JSON.stringify(data);
|
||||
if (key === lastViewsKeyRef.current) return;
|
||||
lastViewsKeyRef.current = key;
|
||||
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
|
||||
viewsTimerRef.current = setTimeout(() => {
|
||||
fetchViews(uuid, data, fetchGenRef.current);
|
||||
}, SCHEMA_REFRESH_DEBOUNCE_MS);
|
||||
},
|
||||
[fetchViews],
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Layer change — fetch runtime schema, clear downstream state
|
||||
// =========================================================================
|
||||
|
||||
const handleLayerChange = useCallback(
|
||||
async (uuid: string) => {
|
||||
fetchGenRef.current += 1;
|
||||
const gen = fetchGenRef.current;
|
||||
|
||||
setSelectedLayerUuid(uuid);
|
||||
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
|
||||
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
|
||||
setRuntimeSchema(null);
|
||||
setRuntimeUiSchema(undefined);
|
||||
setRuntimeData({});
|
||||
errorsRef.current = [];
|
||||
dynamicDepsRef.current = {};
|
||||
lastDepSnapshotRef.current = '';
|
||||
setAvailableViews([]);
|
||||
setSelectedViewNames([]);
|
||||
lastViewsKeyRef.current = '';
|
||||
|
||||
setLoadingRuntime(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.post({
|
||||
endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
|
||||
jsonPayload: {},
|
||||
});
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
const schema = json.result;
|
||||
if (
|
||||
!schema?.properties ||
|
||||
Object.keys(schema.properties).length === 0
|
||||
) {
|
||||
// No runtime config needed — fetch views right away
|
||||
fetchViews(uuid, {}, gen);
|
||||
} else {
|
||||
applyRuntimeSchema(schema);
|
||||
}
|
||||
} catch {
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
addDangerToast(
|
||||
t('An error occurred while fetching the runtime schema'),
|
||||
);
|
||||
} finally {
|
||||
if (gen === fetchGenRef.current) setLoadingRuntime(false);
|
||||
}
|
||||
},
|
||||
[applyRuntimeSchema, fetchViews, addDangerToast],
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Runtime form change — refresh dynamic fields or auto-fetch views
|
||||
// =========================================================================
|
||||
|
||||
const handleRuntimeFormChange = useCallback(
|
||||
({
|
||||
data,
|
||||
errors,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
errors?: ErrorObject[];
|
||||
}) => {
|
||||
setRuntimeData(data);
|
||||
errorsRef.current = errors ?? [];
|
||||
|
||||
if (!selectedLayerUuid) return;
|
||||
const gen = fetchGenRef.current;
|
||||
|
||||
// Dynamic deps changed → refresh schema (e.g. database → schema)
|
||||
const dynamicDeps = dynamicDepsRef.current;
|
||||
if (Object.keys(dynamicDeps).length > 0) {
|
||||
const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
|
||||
areDependenciesSatisfied(deps, data),
|
||||
);
|
||||
if (hasSatisfiedDeps) {
|
||||
const snapshot = serializeDependencyValues(dynamicDeps, data);
|
||||
if (snapshot !== lastDepSnapshotRef.current) {
|
||||
lastDepSnapshotRef.current = snapshot;
|
||||
// Config is changing — clear views
|
||||
setAvailableViews([]);
|
||||
setSelectedViewNames([]);
|
||||
lastViewsKeyRef.current = '';
|
||||
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
|
||||
const uuid = selectedLayerUuid;
|
||||
schemaTimerRef.current = setTimeout(async () => {
|
||||
setRefreshingSchema(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.post({
|
||||
endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
|
||||
jsonPayload: { runtime_data: data },
|
||||
});
|
||||
if (gen !== fetchGenRef.current) return;
|
||||
applyRuntimeSchema(json.result);
|
||||
} catch {
|
||||
// Silent fail on refresh — form still works
|
||||
} finally {
|
||||
if (gen === fetchGenRef.current) setRefreshingSchema(false);
|
||||
}
|
||||
}, SCHEMA_REFRESH_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No schema refresh needed — fetch views if form is valid
|
||||
if (errorsRef.current.length === 0) {
|
||||
scheduleFetchViews(selectedLayerUuid, data);
|
||||
}
|
||||
},
|
||||
[selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews],
|
||||
);
|
||||
|
||||
// After a schema refresh settles, JSON Forms re-validates and fires
|
||||
// onChange → handleRuntimeFormChange handles view fetching. As a fallback
|
||||
// (in case onChange doesn't fire), try once refreshingSchema flips false.
|
||||
const prevRefreshingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (prevRefreshingRef.current && !refreshingSchema && selectedLayerUuid) {
|
||||
const timer = setTimeout(() => {
|
||||
if (errorsRef.current.length === 0) {
|
||||
scheduleFetchViews(selectedLayerUuid, runtimeData);
|
||||
}
|
||||
}, 100);
|
||||
prevRefreshingRef.current = false;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
prevRefreshingRef.current = refreshingSchema;
|
||||
return undefined;
|
||||
}, [refreshingSchema, selectedLayerUuid, runtimeData, scheduleFetchViews]);
|
||||
|
||||
// =========================================================================
|
||||
// Modal open / close
|
||||
// =========================================================================
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
fetchLayers();
|
||||
} else {
|
||||
fetchGenRef.current += 1;
|
||||
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
|
||||
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
|
||||
setLayers([]);
|
||||
setSelectedLayerUuid(null);
|
||||
setLoadingLayers(false);
|
||||
setRuntimeSchema(null);
|
||||
setRuntimeUiSchema(undefined);
|
||||
setRuntimeData({});
|
||||
setLoadingRuntime(false);
|
||||
setRefreshingSchema(false);
|
||||
errorsRef.current = [];
|
||||
dynamicDepsRef.current = {};
|
||||
lastDepSnapshotRef.current = '';
|
||||
setAvailableViews([]);
|
||||
setSelectedViewNames([]);
|
||||
setLoadingViews(false);
|
||||
setSaving(false);
|
||||
lastViewsKeyRef.current = '';
|
||||
}
|
||||
}, [show]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// =========================================================================
|
||||
// Save
|
||||
// =========================================================================
|
||||
|
||||
const newViewCount = availableViews.filter(
|
||||
v => selectedViewNames.includes(v.name) && !v.already_added,
|
||||
).length;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedLayerUuid || newViewCount === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const viewsToCreate = availableViews
|
||||
.filter(v => selectedViewNames.includes(v.name) && !v.already_added)
|
||||
.map(v => ({
|
||||
name: v.name,
|
||||
semantic_layer_uuid: selectedLayerUuid,
|
||||
configuration: runtimeData,
|
||||
}));
|
||||
|
||||
await SupersetClient.post({
|
||||
endpoint: '/api/v1/semantic_view/',
|
||||
jsonPayload: { views: viewsToCreate },
|
||||
});
|
||||
|
||||
addSuccessToast(t('%s semantic view(s) added', viewsToCreate.length));
|
||||
onSuccess();
|
||||
onHide();
|
||||
} catch {
|
||||
addDangerToast(t('An error occurred while adding semantic views'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Render
|
||||
// =========================================================================
|
||||
|
||||
const hasRuntimeFields =
|
||||
runtimeSchema?.properties &&
|
||||
Object.keys(runtimeSchema.properties).length > 0;
|
||||
|
||||
const viewsDisabled =
|
||||
loadingViews || (!loadingViews && availableViews.length === 0);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleSave}
|
||||
title={t('Add Semantic View')}
|
||||
icon={<Icons.PlusOutlined />}
|
||||
width={MODAL_STANDARD_WIDTH}
|
||||
saveDisabled={newViewCount === 0 || saving}
|
||||
saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')}
|
||||
saveLoading={saving}
|
||||
>
|
||||
<ModalContent>
|
||||
{/* Semantic Layer */}
|
||||
<ModalFormField label={t('Semantic Layer')}>
|
||||
<Select
|
||||
ariaLabel={t('Semantic layer')}
|
||||
placeholder={t('Select a semantic layer')}
|
||||
loading={loadingLayers}
|
||||
value={selectedLayerUuid}
|
||||
onChange={value => handleLayerChange(value as string)}
|
||||
options={layers.map(l => ({
|
||||
value: l.uuid,
|
||||
label: l.name,
|
||||
}))}
|
||||
getPopupContainer={() => document.body}
|
||||
/>
|
||||
</ModalFormField>
|
||||
|
||||
{/* Loading runtime schema */}
|
||||
{loadingRuntime && (
|
||||
<LoadingContainer>
|
||||
<Spin size="small" />
|
||||
</LoadingContainer>
|
||||
)}
|
||||
|
||||
{/* Source location (runtime config fields) */}
|
||||
{hasRuntimeFields && !loadingRuntime && (
|
||||
<>
|
||||
<SectionLabel>{t('Source location')}</SectionLabel>
|
||||
<VerticalFormFields>
|
||||
<JsonForms
|
||||
schema={runtimeSchema!}
|
||||
uischema={runtimeUiSchema}
|
||||
data={runtimeData}
|
||||
renderers={renderers}
|
||||
cells={cellRegistryEntries}
|
||||
config={{ refreshingSchema, formData: runtimeData }}
|
||||
validationMode="ValidateAndHide"
|
||||
onChange={handleRuntimeFormChange}
|
||||
/>
|
||||
</VerticalFormFields>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Semantic Views — always visible once a layer is selected */}
|
||||
{selectedLayerUuid && !loadingRuntime && (
|
||||
<ModalFormField label={t('Semantic Views')}>
|
||||
<Select
|
||||
ariaLabel={t('Semantic views')}
|
||||
placeholder={t('Select semantic views')}
|
||||
mode="multiple"
|
||||
loading={loadingViews}
|
||||
disabled={viewsDisabled}
|
||||
value={selectedViewNames}
|
||||
onChange={values => setSelectedViewNames(values as string[])}
|
||||
options={availableViews
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(v => ({
|
||||
value: v.name,
|
||||
label: v.already_added
|
||||
? `${v.name} (${t('already added')})`
|
||||
: v.name,
|
||||
disabled: v.already_added,
|
||||
}))}
|
||||
getPopupContainer={() => document.body}
|
||||
/>
|
||||
</ModalFormField>
|
||||
)}
|
||||
</ModalContent>
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
@@ -38,9 +38,11 @@ import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'
|
||||
import { ColumnObject } from 'src/features/datasets/types';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import {
|
||||
Button,
|
||||
ConfirmStatusChange,
|
||||
CertifiedBadge,
|
||||
DeleteModal,
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
InfoTooltip,
|
||||
DatasetTypeLabel,
|
||||
@@ -77,6 +79,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';
|
||||
@@ -267,6 +270,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
null,
|
||||
);
|
||||
|
||||
const [svCurrentlyDeleting, setSvCurrentlyDeleting] =
|
||||
useState<Dataset | null>(null);
|
||||
|
||||
const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
|
||||
useState(false);
|
||||
const [importingDataset, showImportModal] = useState<boolean>(false);
|
||||
const [passwordFields, setPasswordFields] = useState<string[]>([]);
|
||||
const [preparingExport, setPreparingExport] = useState<boolean>(false);
|
||||
@@ -402,6 +410,29 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
[addDangerToast, setPreparingExport],
|
||||
);
|
||||
|
||||
const handleSemanticViewDelete = (sv: Dataset) => {
|
||||
setSvCurrentlyDeleting(sv);
|
||||
};
|
||||
|
||||
const handleSemanticViewDeleteConfirm = () => {
|
||||
if (!svCurrentlyDeleting) return;
|
||||
const { id, table_name: tableName } = svCurrentlyDeleting;
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/semantic_view/${id}`,
|
||||
}).then(
|
||||
() => {
|
||||
setSvCurrentlyDeleting(null);
|
||||
refreshData();
|
||||
addSuccessToast(t('Deleted: %s', tableName));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('There was an issue deleting %s: %s', tableName, errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -551,25 +582,43 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
Cell: ({ row: { original } }: CellProps<Dataset>) => {
|
||||
const isSemanticView = original.kind === 'semantic_view';
|
||||
|
||||
// Semantic view: only show edit button
|
||||
// Semantic view: show edit and delete buttons
|
||||
if (isSemanticView) {
|
||||
if (!canEdit) return null;
|
||||
if (!canEdit && !canDelete) return null;
|
||||
return (
|
||||
<Actions className="actions">
|
||||
<Tooltip
|
||||
id="edit-action-tooltip"
|
||||
title={t('Edit')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={() => setSvCurrentlyEditing(original)}
|
||||
{canDelete && (
|
||||
<Tooltip
|
||||
id="delete-action-tooltip"
|
||||
title={t('Delete')}
|
||||
placement="bottom"
|
||||
>
|
||||
<Icons.EditOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={() => handleSemanticViewDelete(original)}
|
||||
>
|
||||
<Icons.DeleteOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
id="edit-action-tooltip"
|
||||
title={t('Edit')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={() => setSvCurrentlyEditing(original)}
|
||||
>
|
||||
<Icons.EditOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
@@ -881,14 +930,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;
|
||||
@@ -923,21 +1016,47 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
};
|
||||
|
||||
const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/dataset/?q=${rison.encode(
|
||||
datasetsToDelete.map(({ id }) => id),
|
||||
)}`,
|
||||
}).then(
|
||||
({ json = {} }) => {
|
||||
refreshData();
|
||||
addSuccessToast(json.message);
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('There was an issue deleting the selected datasets: %s', errMsg),
|
||||
),
|
||||
),
|
||||
const datasets = datasetsToDelete.filter(
|
||||
d => d.source_type !== 'semantic_layer',
|
||||
);
|
||||
const semanticViews = datasetsToDelete.filter(
|
||||
d => d.source_type === 'semantic_layer',
|
||||
);
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
if (datasets.length) {
|
||||
promises.push(
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/dataset/?q=${rison.encode(
|
||||
datasets.map(({ id }) => id),
|
||||
)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (semanticViews.length) {
|
||||
promises.push(
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/semantic_view/?q=${rison.encode(
|
||||
semanticViews.map(({ id }) => id),
|
||||
)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Promise.allSettled(promises).then(results => {
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
// Always refresh so the list reflects whatever actually got deleted.
|
||||
refreshData();
|
||||
if (failures.length === 0) {
|
||||
addSuccessToast(t('Deleted %s item(s)', datasetsToDelete.length));
|
||||
} else {
|
||||
addDangerToast(
|
||||
t('There was an issue deleting the selected items'),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDatasetDuplicate = (newDatasetName: string) => {
|
||||
@@ -1081,6 +1200,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
title={t('Delete Dataset?')}
|
||||
/>
|
||||
)}
|
||||
{svCurrentlyDeleting && (
|
||||
<DeleteModal
|
||||
description={t(
|
||||
'Are you sure you want to delete %s?',
|
||||
svCurrentlyDeleting.table_name,
|
||||
)}
|
||||
onConfirm={handleSemanticViewDeleteConfirm}
|
||||
onHide={() => setSvCurrentlyDeleting(null)}
|
||||
open
|
||||
title={t('Delete Semantic View?')}
|
||||
/>
|
||||
)}
|
||||
{datasetCurrentlyEditing && (
|
||||
<DatasourceModal
|
||||
datasource={datasetCurrentlyEditing}
|
||||
@@ -1102,6 +1233,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(
|
||||
|
||||
@@ -27,8 +27,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 import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
@@ -67,3 +69,36 @@ 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: str = self._properties.get("semantic_layer_uuid", "")
|
||||
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
name: str = self._properties.get("name", "")
|
||||
configuration: dict[str, Any] = self._properties.get("configuration") or {}
|
||||
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
|
||||
raise ValueError(
|
||||
f"Semantic view '{name}' already exists for this layer"
|
||||
" and configuration"
|
||||
)
|
||||
|
||||
@@ -21,13 +21,18 @@ from functools import partial
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO
|
||||
from superset.semantic_layers.models import SemanticLayer
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,3 +59,57 @@ 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, id_column="id")
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
try:
|
||||
security_manager.raise_for_ownership(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
|
||||
|
||||
class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, model_ids: list[int]):
|
||||
self._model_ids = model_ids
|
||||
self._models: list[SemanticView] = []
|
||||
|
||||
@transaction(
|
||||
on_error=partial(
|
||||
on_error,
|
||||
catches=(SQLAlchemyError,),
|
||||
reraise=SemanticViewDeleteFailedError,
|
||||
)
|
||||
)
|
||||
def run(self) -> None:
|
||||
self.validate()
|
||||
SemanticViewDAO.delete(self._models)
|
||||
|
||||
def validate(self) -> None:
|
||||
self._models = SemanticViewDAO.find_by_ids(self._model_ids, id_column="id")
|
||||
if len(self._models) != len(self._model_ids):
|
||||
raise SemanticViewNotFoundError()
|
||||
for model in self._models:
|
||||
try:
|
||||
security_manager.raise_for_ownership(model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -23,18 +23,28 @@ from flask import make_response, request, Response
|
||||
from flask_appbuilder.api import expose, protect, rison, safe
|
||||
from flask_appbuilder.api.schemas import get_list_schema
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_babel import lazy_gettext as t, ngettext
|
||||
from marshmallow import ValidationError
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from superset import 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 (
|
||||
BulkDeleteSemanticViewCommand,
|
||||
DeleteSemanticLayerCommand,
|
||||
DeleteSemanticViewCommand,
|
||||
)
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
SemanticViewCreateFailedError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewNotFoundError,
|
||||
@@ -45,13 +55,15 @@ 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.datasets.schemas import get_delete_ids_schema
|
||||
from superset.models.core import Database
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.semantic_layers.schemas import (
|
||||
SemanticLayerPostSchema,
|
||||
SemanticLayerPutSchema,
|
||||
SemanticViewPostSchema,
|
||||
SemanticViewPutSchema,
|
||||
)
|
||||
from superset.superset_typing import FlaskResponse
|
||||
@@ -161,10 +173,90 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
allow_browser_login = True
|
||||
class_permission_name = "SemanticView"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
include_route_methods = {"put"}
|
||||
include_route_methods = {"put", "post", "delete", "bulk_delete"}
|
||||
|
||||
edit_model_schema = SemanticViewPutSchema()
|
||||
|
||||
@expose("/", methods=("POST",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@requires_json
|
||||
def post(self) -> Response:
|
||||
"""Bulk create semantic views.
|
||||
---
|
||||
post:
|
||||
summary: Create semantic views
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
views:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
semantic_layer_uuid:
|
||||
type: string
|
||||
configuration:
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
cache_timeout:
|
||||
type: integer
|
||||
responses:
|
||||
201:
|
||||
description: Semantic views created
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
body = request.json or {}
|
||||
views_data = body.get("views", [])
|
||||
if not views_data:
|
||||
return self.response_400(message="No views provided")
|
||||
|
||||
schema = SemanticViewPostSchema()
|
||||
created = []
|
||||
errors = []
|
||||
for view_data in views_data:
|
||||
try:
|
||||
item = schema.load(view_data)
|
||||
except ValidationError as error:
|
||||
errors.append({"name": view_data.get("name"), "error": error.messages})
|
||||
continue
|
||||
try:
|
||||
new_model = CreateSemanticViewCommand(item).run()
|
||||
created.append({"uuid": str(new_model.uuid), "name": new_model.name})
|
||||
except SemanticLayerNotFoundError:
|
||||
errors.append(
|
||||
{"name": view_data.get("name"), "error": "Semantic layer not found"}
|
||||
)
|
||||
except SemanticViewCreateFailedError as ex:
|
||||
logger.error(
|
||||
"Error creating semantic view: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
errors.append({"name": view_data.get("name"), "error": str(ex)})
|
||||
|
||||
result: dict[str, Any] = {"created": created}
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
status = 201 if created else 422
|
||||
return self.response(status, result=result)
|
||||
|
||||
@expose("/<pk>", methods=("PUT",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@@ -238,6 +330,110 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
response = self.response_422(message=str(ex))
|
||||
return response
|
||||
|
||||
@expose("/<pk>", methods=("DELETE",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def delete(self, pk: int) -> Response:
|
||||
"""Delete a semantic view.
|
||||
---
|
||||
delete:
|
||||
summary: Delete a semantic view
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
responses:
|
||||
200:
|
||||
description: Semantic view deleted
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
try:
|
||||
DeleteSemanticViewCommand(pk).run()
|
||||
return self.response(200, message="OK")
|
||||
except SemanticViewNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticViewForbiddenError:
|
||||
return self.response_403()
|
||||
except SemanticViewDeleteFailedError as ex:
|
||||
logger.error(
|
||||
"Error deleting semantic view: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/", methods=("DELETE",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@rison(get_delete_ids_schema)
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def bulk_delete(self, **kwargs: Any) -> Response:
|
||||
"""Bulk delete semantic views.
|
||||
---
|
||||
delete:
|
||||
summary: Bulk delete semantic views
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/get_delete_ids_schema'
|
||||
responses:
|
||||
200:
|
||||
description: Semantic views deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
item_ids: list[int] = kwargs["rison"]
|
||||
try:
|
||||
BulkDeleteSemanticViewCommand(item_ids).run()
|
||||
return self.response(
|
||||
200,
|
||||
message=ngettext(
|
||||
"Deleted %(num)d semantic view",
|
||||
"Deleted %(num)d semantic views",
|
||||
num=len(item_ids),
|
||||
),
|
||||
)
|
||||
except SemanticViewNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticViewForbiddenError:
|
||||
return self.response_403()
|
||||
except SemanticViewDeleteFailedError as ex:
|
||||
logger.error(
|
||||
"Error bulk deleting semantic views: %s",
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
|
||||
class SemanticLayerRestApi(BaseSupersetApi):
|
||||
resource_name = "semantic_layer"
|
||||
@@ -376,6 +572,77 @@ 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
|
||||
logger.error(
|
||||
"Error fetching semantic views for layer %s: %s",
|
||||
uuid,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_400(
|
||||
message=t(
|
||||
"Unable to fetch semantic views. Check the layer configuration."
|
||||
)
|
||||
)
|
||||
|
||||
# Check which views already exist with the same runtime config
|
||||
existing = 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -147,3 +147,84 @@ def test_create_semantic_layer_copies_data(mocker: MockerFixture) -> None:
|
||||
"type": "snowflake",
|
||||
"configuration": {"account": "test"},
|
||||
}
|
||||
|
||||
|
||||
def test_create_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful creation of a semantic view."""
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = True
|
||||
mock_model = MagicMock()
|
||||
mock_model.uuid = "new-uuid"
|
||||
mock_model.name = "orders"
|
||||
dao_view.create.return_value = mock_model
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
|
||||
result = CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao_view.validate_uniqueness.assert_called_once_with(
|
||||
"orders", "layer-uuid", {"db": "prod"}
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_view_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test CreateSemanticViewCommand raises when layer not found."""
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerNotFoundError,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticLayerNotFoundError):
|
||||
CreateSemanticViewCommand({"name": "v", "semantic_layer_uuid": "missing"}).run()
|
||||
|
||||
|
||||
def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
|
||||
"""Test CreateSemanticViewCommand raises on duplicate view."""
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = False
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewCreateFailedError,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewCreateFailedError):
|
||||
CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
@@ -48,3 +48,117 @@ def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
|
||||
with pytest.raises(SemanticLayerNotFoundError):
|
||||
DeleteSemanticLayerCommand("missing-uuid").run()
|
||||
|
||||
|
||||
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful deletion of a semantic view."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
# Admin is owner of everything — no exception raised
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.security_manager"
|
||||
).raise_for_ownership.return_value = None
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
dao.find_by_id.assert_called_once_with(42, id_column="id")
|
||||
dao.delete.assert_called_once_with([mock_model])
|
||||
|
||||
|
||||
def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewForbiddenError is raised for non-owners."""
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = MagicMock()
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.raise_for_ownership",
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
|
||||
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewNotFoundError is raised when view is missing."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = None
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewNotFoundError):
|
||||
DeleteSemanticViewCommand(999).run()
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful bulk deletion of semantic views."""
|
||||
mock_models = [MagicMock(), MagicMock()]
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_ids.return_value = mock_models
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.security_manager"
|
||||
).raise_for_ownership.return_value = None
|
||||
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
dao.find_by_ids.assert_called_once_with([1, 2], id_column="id")
|
||||
dao.delete.assert_called_once_with(mock_models)
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewForbiddenError is raised for non-owners."""
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_ids.return_value = [MagicMock(), MagicMock()]
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.raise_for_ownership",
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewNotFoundError is raised when any id is missing."""
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
# Only one model returned for two requested ids
|
||||
dao.find_by_ids.return_value = [MagicMock()]
|
||||
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewNotFoundError
|
||||
|
||||
with pytest.raises(SemanticViewNotFoundError):
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
@@ -28,6 +28,8 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
SemanticViewCreateFailedError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewNotFoundError,
|
||||
@@ -1488,3 +1490,456 @@ def test_connections_source_type_semantic_layer_only(
|
||||
assert result["source_type"] == "semantic_layer"
|
||||
# Only one query (SemanticLayer), no Database query
|
||||
mock_db_session.query.assert_called_once()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticViewRestApi.post (bulk create) tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_bulk_create(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST / bulk creates semantic views."""
|
||||
new_model = MagicMock()
|
||||
new_model.uuid = uuid_lib.uuid4()
|
||||
new_model.name = "View 1"
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.CreateSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.return_value = new_model
|
||||
|
||||
payload = {
|
||||
"views": [
|
||||
{
|
||||
"name": "View 1",
|
||||
"semantic_layer_uuid": str(uuid_lib.uuid4()),
|
||||
"configuration": {"database": "db1"},
|
||||
},
|
||||
],
|
||||
}
|
||||
response = client.post("/api/v1/semantic_view/", json=payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
result = response.json["result"]
|
||||
assert len(result["created"]) == 1
|
||||
assert result["created"][0]["name"] == "View 1"
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_empty_views(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
"""Test POST / returns 400 when no views provided."""
|
||||
response = client.post("/api/v1/semantic_view/", json={"views": []})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_validation_error(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST / collects validation errors for individual views."""
|
||||
# Missing required field "semantic_layer_uuid"
|
||||
payload = {
|
||||
"views": [
|
||||
{"name": "Bad View"},
|
||||
],
|
||||
}
|
||||
response = client.post("/api/v1/semantic_view/", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
result = response.json["result"]
|
||||
assert len(result["errors"]) == 1
|
||||
assert result["errors"][0]["name"] == "Bad View"
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_layer_not_found(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST / collects layer-not-found errors."""
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.CreateSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticLayerNotFoundError()
|
||||
|
||||
payload = {
|
||||
"views": [
|
||||
{
|
||||
"name": "View 1",
|
||||
"semantic_layer_uuid": str(uuid_lib.uuid4()),
|
||||
"configuration": {},
|
||||
},
|
||||
],
|
||||
}
|
||||
response = client.post("/api/v1/semantic_view/", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
result = response.json["result"]
|
||||
assert len(result["errors"]) == 1
|
||||
assert result["errors"][0]["error"] == "Semantic layer not found"
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_create_failed(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST / collects create-failed errors."""
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.CreateSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticViewCreateFailedError()
|
||||
|
||||
payload = {
|
||||
"views": [
|
||||
{
|
||||
"name": "View 1",
|
||||
"semantic_layer_uuid": str(uuid_lib.uuid4()),
|
||||
"configuration": {},
|
||||
},
|
||||
],
|
||||
}
|
||||
response = client.post("/api/v1/semantic_view/", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
result = response.json["result"]
|
||||
assert len(result["errors"]) == 1
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_partial_success(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST / returns 201 with partial success (some created, some errors)."""
|
||||
new_model = MagicMock()
|
||||
new_model.uuid = uuid_lib.uuid4()
|
||||
new_model.name = "Good View"
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.CreateSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = [
|
||||
new_model,
|
||||
SemanticLayerNotFoundError(),
|
||||
]
|
||||
|
||||
layer_uuid = str(uuid_lib.uuid4())
|
||||
payload = {
|
||||
"views": [
|
||||
{
|
||||
"name": "Good View",
|
||||
"semantic_layer_uuid": layer_uuid,
|
||||
"configuration": {},
|
||||
},
|
||||
{
|
||||
"name": "Bad View",
|
||||
"semantic_layer_uuid": layer_uuid,
|
||||
"configuration": {},
|
||||
},
|
||||
],
|
||||
}
|
||||
response = client.post("/api/v1/semantic_view/", json=payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
result = response.json["result"]
|
||||
assert len(result["created"]) == 1
|
||||
assert len(result["errors"]) == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticViewRestApi.delete tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_delete_semantic_view(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test DELETE /<pk> deletes a semantic view."""
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.DeleteSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.return_value = None
|
||||
|
||||
response = client.delete("/api/v1/semantic_view/1")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_command.assert_called_once_with("1")
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_delete_semantic_view_not_found(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test DELETE /<pk> returns 404 when view not found."""
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.DeleteSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticViewNotFoundError()
|
||||
|
||||
response = client.delete("/api/v1/semantic_view/999")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_delete_semantic_view_failed(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test DELETE /<pk> returns 422 when deletion fails."""
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.DeleteSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticViewDeleteFailedError()
|
||||
|
||||
response = client.delete("/api/v1/semantic_view/1")
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticViewRestApi.bulk_delete tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_bulk_delete_semantic_view(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test DELETE / deletes multiple semantic views and returns a count message."""
|
||||
import prison as rison_lib
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.return_value = None
|
||||
|
||||
q = rison_lib.dumps([1, 2, 3])
|
||||
response = client.delete(f"/api/v1/semantic_view/?q={q}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "3" in response.json["message"]
|
||||
mock_command.assert_called_once_with([1, 2, 3])
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_bulk_delete_semantic_view_not_found(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test DELETE / returns 404 when any id is missing."""
|
||||
import prison as rison_lib
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticViewNotFoundError()
|
||||
|
||||
q = rison_lib.dumps([1, 999])
|
||||
response = client.delete(f"/api/v1/semantic_view/?q={q}")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_bulk_delete_semantic_view_failed(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test DELETE / returns 422 when deletion fails."""
|
||||
import prison as rison_lib
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticViewDeleteFailedError()
|
||||
|
||||
q = rison_lib.dumps([1, 2])
|
||||
response = client.delete(f"/api/v1/semantic_view/?q={q}")
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticLayerRestApi.views tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/views returns available views."""
|
||||
test_uuid = str(uuid_lib.uuid4())
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = uuid_lib.uuid4()
|
||||
|
||||
mock_view1 = MagicMock()
|
||||
mock_view1.name = "View A"
|
||||
mock_view2 = MagicMock()
|
||||
mock_view2.name = "View B"
|
||||
mock_layer.implementation.get_semantic_views.return_value = [
|
||||
mock_view1,
|
||||
mock_view2,
|
||||
]
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = mock_layer
|
||||
|
||||
mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
|
||||
mock_sv_dao.find_by_semantic_layer.return_value = []
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{test_uuid}/views",
|
||||
json={"runtime_data": {"database": "mydb"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json["result"]
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "View A"
|
||||
assert result[0]["already_added"] is False
|
||||
assert result[1]["name"] == "View B"
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views_with_existing(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/views marks already-added views."""
|
||||
test_uuid = str(uuid_lib.uuid4())
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = uuid_lib.uuid4()
|
||||
|
||||
mock_view = MagicMock()
|
||||
mock_view.name = "Existing View"
|
||||
mock_layer.implementation.get_semantic_views.return_value = [mock_view]
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = mock_layer
|
||||
|
||||
existing_view = MagicMock()
|
||||
existing_view.name = "Existing View"
|
||||
existing_view.configuration = '{"database": "mydb"}'
|
||||
|
||||
mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
|
||||
mock_sv_dao.find_by_semantic_layer.return_value = [existing_view]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{test_uuid}/views",
|
||||
json={"runtime_data": {"database": "mydb"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json["result"]
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Existing View"
|
||||
assert result[0]["already_added"] is True
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views_not_found(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/views returns 404 when layer not found."""
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = None
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{uuid_lib.uuid4()}/views",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views_exception(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/views returns 400 when implementation raises."""
|
||||
test_uuid = str(uuid_lib.uuid4())
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = uuid_lib.uuid4()
|
||||
mock_layer.implementation.get_semantic_views.side_effect = ValueError(
|
||||
"Connection failed"
|
||||
)
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = mock_layer
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{test_uuid}/views",
|
||||
json={"runtime_data": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Unable to fetch semantic views" in response.json["message"]
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views_existing_dict_config(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/views handles dict configuration on existing views."""
|
||||
test_uuid = str(uuid_lib.uuid4())
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = uuid_lib.uuid4()
|
||||
|
||||
mock_view = MagicMock()
|
||||
mock_view.name = "View X"
|
||||
mock_layer.implementation.get_semantic_views.return_value = [mock_view]
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = mock_layer
|
||||
|
||||
existing_view = MagicMock()
|
||||
existing_view.name = "View X"
|
||||
existing_view.configuration = {"key": "val"} # dict, not string
|
||||
|
||||
mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
|
||||
mock_sv_dao.find_by_semantic_layer.return_value = [existing_view]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{test_uuid}/views",
|
||||
json={"runtime_data": {"key": "val"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json["result"]
|
||||
assert result[0]["already_added"] is True
|
||||
|
||||
Reference in New Issue
Block a user