This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch semantic-layer-ui-semantic-layer in repository https://gitbox.apache.org/repos/asf/superset.git
commit 4c36bbeab30c526716d4b5e5065b5f4bbe6989cf Author: Beto Dealmeida <[email protected]> AuthorDate: Fri Feb 13 19:04:50 2026 -0500 Cleanup --- .../src/superset_core/semantic_layers/config.py | 73 ++++++++++++++++++ .../semantic_layers/semantic_layer.py | 2 + superset/semantic_layers/api.py | 87 ++++++++++++++++++++-- 3 files changed, 157 insertions(+), 5 deletions(-) diff --git a/superset-core/src/superset_core/semantic_layers/config.py b/superset-core/src/superset_core/semantic_layers/config.py new file mode 100644 index 00000000000..c1b92a21008 --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/config.py @@ -0,0 +1,73 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +def build_configuration_schema( + config_class: type[BaseModel], + configuration: BaseModel | None = None, +) -> dict[str, Any]: + """ + Build a JSON schema from a Pydantic configuration class. + + Handles generic boilerplate that any semantic layer with dynamic fields needs: + + - Reorders properties to match model field order (Pydantic sorts alphabetically) + - When ``configuration`` is None, sets ``enum: []`` on all ``x-dynamic`` properties + so the frontend renders them as empty dropdowns + + Semantic layer implementations call this instead of ``model_json_schema()`` directly, + then only need to add their own dynamic population logic. + """ + schema = config_class.model_json_schema() + + # Pydantic sorts properties alphabetically; restore model field order + field_order = [ + field.alias or name + for name, field in config_class.model_fields.items() + ] + schema["properties"] = { + key: schema["properties"][key] + for key in field_order + if key in schema["properties"] + } + + if configuration is None: + for prop_schema in schema["properties"].values(): + if prop_schema.get("x-dynamic"): + prop_schema["enum"] = [] + + return schema + + +def check_dependencies( + prop_schema: dict[str, Any], + configuration: BaseModel, +) -> bool: + """ + Check whether a dynamic property's dependencies are satisfied. + + Reads the ``x-dependsOn`` list from the property schema and returns ``True`` + when every referenced attribute on ``configuration`` is truthy. + """ + dependencies = prop_schema.get("x-dependsOn", []) + return all(getattr(configuration, dep, None) for dep in dependencies) diff --git a/superset-core/src/superset_core/semantic_layers/semantic_layer.py b/superset-core/src/superset_core/semantic_layers/semantic_layer.py index 615014f8c1b..4ab01d01842 100644 --- a/superset-core/src/superset_core/semantic_layers/semantic_layer.py +++ b/superset-core/src/superset_core/semantic_layers/semantic_layer.py @@ -33,6 +33,8 @@ class SemanticLayer(Protocol[ConfigT, SemanticViewT]): A protocol for semantic layers. """ + configuration_class: type[BaseModel] + @classmethod def from_configuration( cls, diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index a0a87e55081..bb94a0e4b91 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -24,6 +24,7 @@ from flask import make_response, request, Response from flask_appbuilder.api import expose, protect, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from marshmallow import ValidationError +from pydantic import ValidationError as PydanticValidationError from superset import event_logger from superset.commands.semantic_layer.create import CreateSemanticLayerCommand @@ -73,6 +74,79 @@ def _serialize_layer(layer: SemanticLayer) -> dict[str, Any]: } +def _infer_discriminators( + schema: dict[str, Any], + data: dict[str, Any], +) -> dict[str, Any]: + """ + Infer discriminator values for union fields when the frontend omits them. + + Walks the schema's properties looking for discriminated unions (fields with a + ``discriminator.mapping``). For each one, tries to match the submitted data + against one of the variants by checking which variant's required fields are + present, then injects the discriminator value. + """ + defs = schema.get("$defs", {}) + for prop_name, prop_schema in schema.get("properties", {}).items(): + value = data.get(prop_name) + if not isinstance(value, dict): + continue + + # Find discriminated union via discriminator mapping + mapping = ( + prop_schema.get("discriminator", {}).get("mapping") + if "discriminator" in prop_schema + else None + ) + if not mapping: + continue + + discriminator_field = prop_schema["discriminator"].get("propertyName") + if not discriminator_field or discriminator_field in value: + continue + + # Try each variant: match by required fields present in the data + for disc_value, ref in mapping.items(): + ref_name = ref.rsplit("/", 1)[-1] if "/" in ref else ref + variant_def = defs.get(ref_name, {}) + required = set(variant_def.get("required", [])) + # Exclude the discriminator itself from the check + required.discard(discriminator_field) + if required and required.issubset(value.keys()): + data = { + **data, + prop_name: {**value, discriminator_field: disc_value}, + } + break + + return data + + +def _parse_partial_config( + cls: Any, + config: dict[str, Any], +) -> Any: + """ + Parse a partial configuration, handling discriminator inference and + falling back to lenient validation when strict parsing fails. + """ + config_class = cls.configuration_class + + # Infer discriminator values the frontend may have omitted + schema = config_class.model_json_schema() + config = _infer_discriminators(schema, config) + + try: + return config_class.model_validate(config) + except (PydanticValidationError, ValueError): + pass + + try: + return config_class.model_validate(config, context={"partial": True}) + except (PydanticValidationError, ValueError): + return None + + class SemanticViewRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(SemanticView) @@ -224,12 +298,15 @@ class SemanticLayerRestApi(BaseSupersetApi): parsed_config = None if config := body.get("configuration"): - try: - parsed_config = cls.from_configuration(config).configuration - except Exception: # pylint: disable=broad-except - parsed_config = None + parsed_config = _parse_partial_config(cls, config) + + try: + schema = cls.get_configuration_schema(parsed_config) + except Exception: # pylint: disable=broad-except + # Connection or query failures during schema enrichment should not + # prevent the form from rendering — return the base schema instead. + schema = cls.get_configuration_schema(None) - schema = cls.get_configuration_schema(parsed_config) resp = make_response(json.dumps({"result": schema}, sort_keys=False), 200) resp.headers["Content-Type"] = "application/json; charset=utf-8" return resp
