jerryshao commented on code in PR #13571: URL: https://github.com/apache/gravitino/pull/13571#discussion_r4130837060
########## core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java: ########## @@ -0,0 +1,715 @@ +/* + * 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. + */ +package org.apache.gravitino.semantic; + +import static org.apache.gravitino.semantic.SemanticModel.DEFAULT_OSSIE_VERSION; +import static org.apache.gravitino.semantic.SemanticModel.PROPERTY_OSSIE_VERSION; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import javax.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; +import org.apache.gravitino.dto.requests.SemanticModelCreateRequest; +import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO; +import org.apache.gravitino.exceptions.IllegalSemanticModelException; + +/** Converts between standalone Apache Ossie documents and Gravitino Semantic Models. */ +public final class OssieSemanticModelDocumentConverter { + + /** Maximum accepted document length in characters. */ + public static final int MAX_DOCUMENT_LENGTH = 4 * 1024 * 1024; + + private static final String GRAVITINO_VENDOR = "GRAVITINO"; + private static final String INTERCHANGE_MARKER = "_apache_gravitino_interchange"; + private static final int INTERCHANGE_MARKER_VERSION = 1; + private static final int MAX_NESTING_DEPTH = 100; + + private static final Set<String> ROOT_PROPERTIES = + Set.of( + "version", + "name", + "description", + "ai_context", + "datasets", + "relationships", + "metrics", + "custom_extensions"); + private static final Set<String> DATASET_PROPERTIES = + Set.of( + "name", + "source", + "primary_key", + "unique_keys", + "description", + "ai_context", + "fields", + "custom_extensions"); + private static final Set<String> FIELD_PROPERTIES = + Set.of( + "name", + "expression", + "dimension", + "label", + "description", + "datatype", + "ai_context", + "custom_extensions"); + private static final Set<String> RELATIONSHIP_PROPERTIES = + Set.of("name", "from", "to", "from_columns", "to_columns", "ai_context", "custom_extensions"); + private static final Set<String> METRIC_PROPERTIES = + Set.of("name", "expression", "description", "datatype", "ai_context", "custom_extensions"); + private static final Set<String> EXPRESSION_PROPERTIES = Set.of("dialects"); + private static final Set<String> DIALECT_EXPRESSION_PROPERTIES = Set.of("dialect", "expression"); + private static final Set<String> DIMENSION_PROPERTIES = Set.of("is_time"); + private static final Set<String> CUSTOM_EXTENSION_PROPERTIES = Set.of("vendor_name", "data"); + private static final Set<String> OSSIE_DIALECTS = + Set.of( + "ANSI_SQL", + "SNOWFLAKE", + "MDX", + "TABLEAU", + "DATABRICKS", + "MAQL", + "BIGQUERY", + "SIGMA", + "THOUGHTSPOT", + "DAX", + "OSSIE_SQL_2026"); Review Comment: [Important] This hard-coded dialect allowlist both duplicates and contradicts the public API contract, and it makes valid models un-exportable. `api/src/main/java/org/apache/gravitino/semantic/Dialects.java:25-28` states: *'These constants match the dialects defined by the pinned Apache Ossie schema. Applications may use other non-empty identifiers; Gravitino preserves them without implicit conversion or fallback so consumers can select the dialects they support.'* Two problems follow: 1. **The two lists have already diverged.** `Dialects` declares 7 constants (`ANSI_SQL`, `SNOWFLAKE`, `MDX`, `TABLEAU`, `DATABRICKS`, `MAQL`, `BIGQUERY`); `OSSIE_DIALECTS` here declares 11, adding `SIGMA`, `THOUGHTSPOT`, `DAX` and `OSSIE_SQL_2026`. If this set is right, the `Dialects` javadoc claim is now false and that class is missing four constants. Please derive `OSSIE_DIALECTS` from `Dialects` (extending it as needed) so there is one source of truth. 2. **Export hard-fails on dialects the API explicitly permits.** Because `exportDocument` re-validates its own output through `toCreateRequest` (line 164), this check also runs on the export path. A Semantic Model legally created through `POST .../semantic-models` with, say, `TRINO` can never be exported -- `GET .../semantic-models/{name}/ossie` returns 400 forever. This PR's own test `core/src/test/java/org/apache/gravitino/semantic/TestOssieSemanticModelDocumentConverter.java:281-301` codifies exactly that, using `TRINO`, a dialect Gravitino ships a connector for. Suggest keeping the strict allowlist for **import** (rejecting a bad document at the door is right), but on **export** either passing unknown dialects through, or failing with an error that names the offending dataset/metric and lists the exportable dialects. Rejecting a read of already-persisted data with no recovery path is the part that needs to change. Verified by: read `Dialects.java` and `DialectExpressionDTO.java` in full at `da4c250`; confirmed `Dataset` and `SemanticModelCreateRequest` impose no dialect restriction; traced `exportDocument:164 -> toCreateRequest -> transformOssieDefinition -> transformOssieMetric -> transformOssieExpression` to confirm the check runs on export; read the PR's own `testRejectsUnsupportedNativeDialectOnExport`. ########## server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java: ########## @@ -116,6 +115,53 @@ public Response createSemanticModel( } } + /** + * Imports a standalone Apache Ossie YAML or JSON document as a Semantic Model. + * + * @param metalake The metalake name. + * @param catalog The catalog name. + * @param schema The schema name. + * @param document The standalone Ossie document. + * @return A native response containing the created Semantic Model. + */ + @POST + @Path("ossie") + @Consumes({MediaType.APPLICATION_JSON, OSSIE_YAML_MEDIA_TYPE, OSSIE_X_YAML_MEDIA_TYPE}) Review Comment: [Nit] This `@Consumes` list will 415 several common ways of posting a YAML file. `text/yaml` and `text/plain` are both missing. In particular `curl --data-binary @model.yaml <url>` sends `application/x-www-form-urlencoded` by default, and many YAML tools send `text/yaml`, so the documented 'import a standalone Ossie YAML document' flow needs an explicit `-H 'Content-Type: application/yaml'` to work at all. The Gravitino Java client is fine here -- `clients/client-java/.../HTTPClient.java:397-400` sends `Content-Type: application/json`, which is in the list. This is about curl/CLI ergonomics for the YAML case. Adding `MediaType.TEXT_PLAIN` and `text/yaml` would cover it, since `parseDocument` already accepts either syntax regardless of the declared type. Verified by: read the annotation at `da4c250` and `HTTPClient.java` to confirm which content type the official client sends. ########## core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java: ########## @@ -0,0 +1,715 @@ +/* + * 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. + */ +package org.apache.gravitino.semantic; + +import static org.apache.gravitino.semantic.SemanticModel.DEFAULT_OSSIE_VERSION; +import static org.apache.gravitino.semantic.SemanticModel.PROPERTY_OSSIE_VERSION; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import javax.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; +import org.apache.gravitino.dto.requests.SemanticModelCreateRequest; +import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO; +import org.apache.gravitino.exceptions.IllegalSemanticModelException; + +/** Converts between standalone Apache Ossie documents and Gravitino Semantic Models. */ +public final class OssieSemanticModelDocumentConverter { + + /** Maximum accepted document length in characters. */ + public static final int MAX_DOCUMENT_LENGTH = 4 * 1024 * 1024; + + private static final String GRAVITINO_VENDOR = "GRAVITINO"; + private static final String INTERCHANGE_MARKER = "_apache_gravitino_interchange"; + private static final int INTERCHANGE_MARKER_VERSION = 1; + private static final int MAX_NESTING_DEPTH = 100; + + private static final Set<String> ROOT_PROPERTIES = + Set.of( + "version", + "name", + "description", + "ai_context", + "datasets", + "relationships", + "metrics", + "custom_extensions"); + private static final Set<String> DATASET_PROPERTIES = + Set.of( + "name", + "source", + "primary_key", + "unique_keys", + "description", + "ai_context", + "fields", + "custom_extensions"); + private static final Set<String> FIELD_PROPERTIES = + Set.of( + "name", + "expression", + "dimension", + "label", + "description", + "datatype", + "ai_context", + "custom_extensions"); + private static final Set<String> RELATIONSHIP_PROPERTIES = + Set.of("name", "from", "to", "from_columns", "to_columns", "ai_context", "custom_extensions"); + private static final Set<String> METRIC_PROPERTIES = + Set.of("name", "expression", "description", "datatype", "ai_context", "custom_extensions"); + private static final Set<String> EXPRESSION_PROPERTIES = Set.of("dialects"); + private static final Set<String> DIALECT_EXPRESSION_PROPERTIES = Set.of("dialect", "expression"); + private static final Set<String> DIMENSION_PROPERTIES = Set.of("is_time"); + private static final Set<String> CUSTOM_EXTENSION_PROPERTIES = Set.of("vendor_name", "data"); + private static final Set<String> OSSIE_DIALECTS = + Set.of( + "ANSI_SQL", + "SNOWFLAKE", + "MDX", + "TABLEAU", + "DATABRICKS", + "MAQL", + "BIGQUERY", + "SIGMA", + "THOUGHTSPOT", + "DAX", + "OSSIE_SQL_2026"); + + private static final ObjectMapper JSON_MAPPER = createJsonMapper(); + private static final ObjectMapper YAML_MAPPER = createYamlMapper(); + + /** Supported Ossie document serialization formats. */ + public enum Format { + /** YAML serialization. */ + YAML, + /** JSON serialization. */ + JSON + } + + private OssieSemanticModelDocumentConverter() {} + + /** + * Converts one standalone Apache Ossie YAML or JSON document into a create request. + * + * @param document The standalone Ossie document. + * @return The converted Semantic Model create request. + * @throws IllegalSemanticModelException If the document cannot be parsed or represented by + * Gravitino. + */ + public static SemanticModelCreateRequest importDocument(String document) { + ObjectNode root = parseDocument(document); + return toCreateRequest(root); + } + + /** + * Exports one Gravitino Semantic Model as a standalone Apache Ossie document. + * + * @param semanticModel The Semantic Model to export. + * @param format The requested serialization format. + * @return The serialized Ossie document. + * @throws IllegalSemanticModelException If the model cannot be represented as a valid Ossie + * document. + */ + public static String exportDocument(SemanticModel semanticModel, Format format) { + Objects.requireNonNull(semanticModel, "semanticModel must not be null"); + Objects.requireNonNull(format, "format must not be null"); + + ObjectNode definition = + JSON_MAPPER.valueToTree( + SemanticModelDefinitionDTO.fromDefinition(semanticModel.definition())); + transformNativeDefinition(definition); + + ObjectNode root = JSON_MAPPER.createObjectNode(); + root.put("version", ossieVersion(semanticModel.properties())); + root.put("name", semanticModel.name()); + if (semanticModel.comment() != null) { + root.put("description", semanticModel.comment()); + } + definition.fields().forEachRemaining(entry -> root.set(entry.getKey(), entry.getValue())); + stashProperties(root, semanticModel.properties()); + + // Validate the generated document through the same conversion path used for imports. + toCreateRequest(root.deepCopy()); + + try { + if (format == Format.JSON) { + return JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(root) + "\n"; + } + return YAML_MAPPER.writeValueAsString(root); + } catch (JsonProcessingException e) { + throw new IllegalSemanticModelException( + e, "Cannot serialize Apache Ossie document: %s", e.getOriginalMessage()); + } + } + + private static ObjectMapper createJsonMapper() { + return JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS) + .build() + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + private static ObjectMapper createYamlMapper() { + YAMLFactory factory = + YAMLFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build(); + factory.setStreamReadConstraints( + StreamReadConstraints.builder() + .maxNestingDepth(MAX_NESTING_DEPTH) + .maxStringLength(MAX_DOCUMENT_LENGTH) + .build()); + return new ObjectMapper(factory) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + private static ObjectNode parseDocument(String document) { + if (StringUtils.isBlank(document)) { + throw new IllegalSemanticModelException("Apache Ossie document must not be empty"); + } + if (document.length() > MAX_DOCUMENT_LENGTH) { + throw new IllegalSemanticModelException( + "Apache Ossie document exceeds the maximum length of %s characters", MAX_DOCUMENT_LENGTH); + } + + try { + JsonNode parsed = YAML_MAPPER.readTree(document); + if (!(parsed instanceof ObjectNode)) { + throw invalid("$", "document root must be an object"); + } + return (ObjectNode) parsed; + } catch (JsonProcessingException e) { + throw new IllegalSemanticModelException( + e, "Cannot parse Apache Ossie YAML or JSON: %s", e.getOriginalMessage()); + } + } + + private static SemanticModelCreateRequest toCreateRequest(ObjectNode root) { + validateObject(root, "$", ROOT_PROPERTIES); + String ossieVersion = validateVersion(root.get("version")); + + Map<String, String> properties = new LinkedHashMap<>(extractProperties(root)); + String extensionVersion = properties.get(PROPERTY_OSSIE_VERSION); + if (extensionVersion != null && !extensionVersion.equals(ossieVersion)) { + throw invalid( + "$.custom_extensions", + "Gravitino property '" + PROPERTY_OSSIE_VERSION + "' conflicts with $.version"); + } + properties.put(PROPERTY_OSSIE_VERSION, ossieVersion); + ObjectNode definition = JSON_MAPPER.createObjectNode(); + copy(root, definition, "ai_context"); + copy(root, definition, "datasets"); + copy(root, definition, "relationships"); + copy(root, definition, "metrics"); + copy(root, definition, "custom_extensions"); + transformOssieDefinition(definition, "$"); + + ObjectNode requestNode = JSON_MAPPER.createObjectNode(); + copy(root, requestNode, "name"); + if (root.has("description")) { + requestNode.set("comment", root.get("description")); + } + requestNode.set("definition", definition); + requestNode.set("properties", JSON_MAPPER.valueToTree(properties)); + + try { + SemanticModelCreateRequest request = + JSON_MAPPER.treeToValue(requestNode, SemanticModelCreateRequest.class); + request.validate(); + request.toDefinition(); + return request; + } catch (JsonProcessingException | IllegalArgumentException e) { + throw new IllegalSemanticModelException( + e, + "Cannot convert Apache Ossie document to a Gravitino Semantic Model: %s", + originalMessage(e)); + } + } + + private static String validateVersion(@Nullable JsonNode version) { + if (version == null || !version.isTextual() || StringUtils.isBlank(version.textValue())) { + throw invalid("$.version", "must be a non-empty string"); + } + return version.textValue(); + } + + private static String ossieVersion(Map<String, String> properties) { + String version = properties.getOrDefault(PROPERTY_OSSIE_VERSION, DEFAULT_OSSIE_VERSION); + if (StringUtils.isBlank(version)) { + throw invalid( + "$.version", + "Semantic Model property '" + PROPERTY_OSSIE_VERSION + "' must not be blank"); + } + return version; + } + + private static void transformOssieDefinition(ObjectNode definition, String path) { + validateAIContext(definition.get("ai_context"), path + ".ai_context"); + transformObjectArray( + definition.get("datasets"), + path + ".datasets", + OssieSemanticModelDocumentConverter::transformOssieDataset); + transformObjectArray( + definition.get("relationships"), + path + ".relationships", + OssieSemanticModelDocumentConverter::transformOssieRelationship); + transformObjectArray( + definition.get("metrics"), + path + ".metrics", + OssieSemanticModelDocumentConverter::transformOssieMetric); + transformOssieCustomExtensions( + definition.get("custom_extensions"), path + ".custom_extensions"); + rename(definition, "ai_context", "aiContext"); + rename(definition, "custom_extensions", "customExtensions"); + } + + private static void transformOssieDataset(ObjectNode dataset, String path) { + validateObject(dataset, path, DATASET_PROPERTIES); + JsonNode source = dataset.get("source"); + if (source != null) { + if (!source.isTextual()) { + throw invalid(path + ".source", "must be a string"); + } + String sourceValue = source.textValue(); + String[] parts = sourceValue.split("\\.", -1); + if (parts.length != 3 || StringUtils.isAnyBlank(parts)) { + throw invalid( + path + ".source", + "must be a three-part catalog.schema.entity identifier; query sources are not supported"); + } + ObjectNode identifier = JSON_MAPPER.createObjectNode(); + identifier.putArray("namespace").add(parts[0]).add(parts[1]); + identifier.put("name", parts[2]); + dataset.set("source", identifier); + } + + validateAIContext(dataset.get("ai_context"), path + ".ai_context"); + transformObjectArray( + dataset.get("fields"), + path + ".fields", + OssieSemanticModelDocumentConverter::transformOssieField); + transformOssieCustomExtensions(dataset.get("custom_extensions"), path + ".custom_extensions"); + rename(dataset, "primary_key", "primaryKey"); + rename(dataset, "unique_keys", "uniqueKeys"); + rename(dataset, "ai_context", "aiContext"); + rename(dataset, "custom_extensions", "customExtensions"); + } + + private static void transformOssieField(ObjectNode field, String path) { + validateObject(field, path, FIELD_PROPERTIES); + transformOssieExpression(field.get("expression"), path + ".expression"); + JsonNode dimension = field.get("dimension"); + if (dimension instanceof ObjectNode) { + validateObject((ObjectNode) dimension, path + ".dimension", DIMENSION_PROPERTIES); + rename((ObjectNode) dimension, "is_time", "isTime"); + } + validateAIContext(field.get("ai_context"), path + ".ai_context"); + transformOssieCustomExtensions(field.get("custom_extensions"), path + ".custom_extensions"); + rename(field, "ai_context", "aiContext"); + rename(field, "custom_extensions", "customExtensions"); + } + + private static void transformOssieRelationship(ObjectNode relationship, String path) { + validateObject(relationship, path, RELATIONSHIP_PROPERTIES); + validateAIContext(relationship.get("ai_context"), path + ".ai_context"); + transformOssieCustomExtensions( + relationship.get("custom_extensions"), path + ".custom_extensions"); + rename(relationship, "from_columns", "fromColumns"); + rename(relationship, "to_columns", "toColumns"); + rename(relationship, "ai_context", "aiContext"); + rename(relationship, "custom_extensions", "customExtensions"); + } + + private static void transformOssieMetric(ObjectNode metric, String path) { + validateObject(metric, path, METRIC_PROPERTIES); + transformOssieExpression(metric.get("expression"), path + ".expression"); + validateAIContext(metric.get("ai_context"), path + ".ai_context"); + transformOssieCustomExtensions(metric.get("custom_extensions"), path + ".custom_extensions"); + rename(metric, "ai_context", "aiContext"); + rename(metric, "custom_extensions", "customExtensions"); + } + + private static void transformOssieExpression(@Nullable JsonNode expression, String path) { + if (!(expression instanceof ObjectNode)) { + return; + } + ObjectNode expressionObject = (ObjectNode) expression; + validateObject(expressionObject, path, EXPRESSION_PROPERTIES); + transformObjectArray( + expressionObject.get("dialects"), + path + ".dialects", + (dialectExpression, dialectPath) -> { + validateObject(dialectExpression, dialectPath, DIALECT_EXPRESSION_PROPERTIES); + JsonNode dialect = dialectExpression.get("dialect"); + if (dialect != null + && dialect.isTextual() + && !OSSIE_DIALECTS.contains(dialect.textValue())) { + throw invalid( + dialectPath + ".dialect", + "unsupported Apache Ossie dialect '" + dialect.textValue() + "'"); + } + }); + } + + private static void transformOssieCustomExtensions(@Nullable JsonNode extensions, String path) { + transformObjectArray( + extensions, + path, + (extension, extensionPath) -> { + validateObject(extension, extensionPath, CUSTOM_EXTENSION_PROPERTIES); + rename(extension, "vendor_name", "vendorName"); + }); + } + + private static void validateAIContext(@Nullable JsonNode context, String path) { + if (context == null) { + return; + } + if (context.isTextual()) { + return; + } + if (!(context instanceof ObjectNode)) { + throw invalid(path, "must be a string or object"); + } + + ObjectNode object = (ObjectNode) context; + validateOptionalText(object, "instructions", path); + validateOptionalStringArray(object, "synonyms", path); + validateOptionalStringArray(object, "examples", path); + } + + private static void transformNativeDefinition(ObjectNode definition) { + transformObjectArray( + definition.get("datasets"), + "$.datasets", + OssieSemanticModelDocumentConverter::transformNativeDataset); + transformObjectArray( + definition.get("relationships"), + "$.relationships", + OssieSemanticModelDocumentConverter::transformNativeRelationship); + transformObjectArray( + definition.get("metrics"), + "$.metrics", + OssieSemanticModelDocumentConverter::transformNativeMetric); + transformNativeCustomExtensions(definition.get("customExtensions"), "$.custom_extensions"); + rename(definition, "aiContext", "ai_context"); + rename(definition, "customExtensions", "custom_extensions"); + } + + private static void transformNativeDataset(ObjectNode dataset, String path) { + JsonNode source = dataset.get("source"); + if (!(source instanceof ObjectNode)) { + throw invalid(path + ".source", "must be a Gravitino source identifier"); + } + JsonNode namespace = source.get("namespace"); + JsonNode name = source.get("name"); + if (!(namespace instanceof ArrayNode) + || namespace.size() != 2 + || !namespace.get(0).isTextual() + || !namespace.get(1).isTextual() + || name == null + || !name.isTextual()) { + throw invalid(path + ".source", "must contain exactly catalog.schema.name"); + } Review Comment: [Important] Export requires `namespace.size() == 2`, but nothing on the create path enforces that shape, so this rejects models the server itself accepted. `Dataset.Builder.build()` (`api/src/main/java/org/apache/gravitino/semantic/Dataset.java:326-331`) validates only that `source != null` -- it never constrains the `NameIdentifier` depth. I also grepped `SemanticModelCreateRequest`, `DatasetDTO` and `SemanticDTOUtils` for any source-shape validation and found none. So `POST .../semantic-models` happily persists a dataset whose source is a one-level identifier (empty namespace) or a four-level one, and `GET .../semantic-models/{name}/ossie` then returns 400 with `$.datasets[0].source: must contain exactly catalog.schema.name` -- on a read, with no way for the user to get their model out. The same asymmetry exists in the opposite direction on import (line 311): the dot-split requires exactly three non-blank parts, so a catalog/schema/entity name containing a dot cannot round-trip either. Suggest either (a) validating the source shape at create time, so the invariant is established where the data enters, or (b) at minimum including the dataset name and the actual offending source in the message so an operator can see which dataset to fix. Option (a) is the one that actually closes the hole. Verified by: read `Dataset.java`, `DatasetDTO.java`, `SemanticModelCreateRequest.java` and `SemanticDTOUtils.java` at `da4c250` and confirmed no source-depth or character validation exists on any create path; traced `exportDocument:152 -> transformNativeDefinition -> transformNativeDataset` to this check. ########## core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java: ########## @@ -0,0 +1,715 @@ +/* + * 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. + */ +package org.apache.gravitino.semantic; + +import static org.apache.gravitino.semantic.SemanticModel.DEFAULT_OSSIE_VERSION; +import static org.apache.gravitino.semantic.SemanticModel.PROPERTY_OSSIE_VERSION; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import javax.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; +import org.apache.gravitino.dto.requests.SemanticModelCreateRequest; +import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO; +import org.apache.gravitino.exceptions.IllegalSemanticModelException; + +/** Converts between standalone Apache Ossie documents and Gravitino Semantic Models. */ +public final class OssieSemanticModelDocumentConverter { + + /** Maximum accepted document length in characters. */ + public static final int MAX_DOCUMENT_LENGTH = 4 * 1024 * 1024; + + private static final String GRAVITINO_VENDOR = "GRAVITINO"; + private static final String INTERCHANGE_MARKER = "_apache_gravitino_interchange"; + private static final int INTERCHANGE_MARKER_VERSION = 1; + private static final int MAX_NESTING_DEPTH = 100; + + private static final Set<String> ROOT_PROPERTIES = + Set.of( + "version", + "name", + "description", + "ai_context", + "datasets", + "relationships", + "metrics", + "custom_extensions"); + private static final Set<String> DATASET_PROPERTIES = + Set.of( + "name", + "source", + "primary_key", + "unique_keys", + "description", + "ai_context", + "fields", + "custom_extensions"); + private static final Set<String> FIELD_PROPERTIES = + Set.of( + "name", + "expression", + "dimension", + "label", + "description", + "datatype", + "ai_context", + "custom_extensions"); + private static final Set<String> RELATIONSHIP_PROPERTIES = + Set.of("name", "from", "to", "from_columns", "to_columns", "ai_context", "custom_extensions"); + private static final Set<String> METRIC_PROPERTIES = + Set.of("name", "expression", "description", "datatype", "ai_context", "custom_extensions"); + private static final Set<String> EXPRESSION_PROPERTIES = Set.of("dialects"); + private static final Set<String> DIALECT_EXPRESSION_PROPERTIES = Set.of("dialect", "expression"); + private static final Set<String> DIMENSION_PROPERTIES = Set.of("is_time"); + private static final Set<String> CUSTOM_EXTENSION_PROPERTIES = Set.of("vendor_name", "data"); + private static final Set<String> OSSIE_DIALECTS = + Set.of( + "ANSI_SQL", + "SNOWFLAKE", + "MDX", + "TABLEAU", + "DATABRICKS", + "MAQL", + "BIGQUERY", + "SIGMA", + "THOUGHTSPOT", + "DAX", + "OSSIE_SQL_2026"); + + private static final ObjectMapper JSON_MAPPER = createJsonMapper(); + private static final ObjectMapper YAML_MAPPER = createYamlMapper(); + + /** Supported Ossie document serialization formats. */ + public enum Format { + /** YAML serialization. */ + YAML, + /** JSON serialization. */ + JSON + } + + private OssieSemanticModelDocumentConverter() {} + + /** + * Converts one standalone Apache Ossie YAML or JSON document into a create request. + * + * @param document The standalone Ossie document. + * @return The converted Semantic Model create request. + * @throws IllegalSemanticModelException If the document cannot be parsed or represented by + * Gravitino. + */ + public static SemanticModelCreateRequest importDocument(String document) { + ObjectNode root = parseDocument(document); + return toCreateRequest(root); + } + + /** + * Exports one Gravitino Semantic Model as a standalone Apache Ossie document. + * + * @param semanticModel The Semantic Model to export. + * @param format The requested serialization format. + * @return The serialized Ossie document. + * @throws IllegalSemanticModelException If the model cannot be represented as a valid Ossie + * document. + */ + public static String exportDocument(SemanticModel semanticModel, Format format) { + Objects.requireNonNull(semanticModel, "semanticModel must not be null"); + Objects.requireNonNull(format, "format must not be null"); + + ObjectNode definition = + JSON_MAPPER.valueToTree( + SemanticModelDefinitionDTO.fromDefinition(semanticModel.definition())); + transformNativeDefinition(definition); + + ObjectNode root = JSON_MAPPER.createObjectNode(); + root.put("version", ossieVersion(semanticModel.properties())); + root.put("name", semanticModel.name()); + if (semanticModel.comment() != null) { + root.put("description", semanticModel.comment()); + } + definition.fields().forEachRemaining(entry -> root.set(entry.getKey(), entry.getValue())); + stashProperties(root, semanticModel.properties()); + + // Validate the generated document through the same conversion path used for imports. + toCreateRequest(root.deepCopy()); + + try { + if (format == Format.JSON) { + return JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(root) + "\n"; + } + return YAML_MAPPER.writeValueAsString(root); + } catch (JsonProcessingException e) { + throw new IllegalSemanticModelException( + e, "Cannot serialize Apache Ossie document: %s", e.getOriginalMessage()); + } + } + + private static ObjectMapper createJsonMapper() { + return JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS) + .build() + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + private static ObjectMapper createYamlMapper() { + YAMLFactory factory = + YAMLFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build(); + factory.setStreamReadConstraints( + StreamReadConstraints.builder() + .maxNestingDepth(MAX_NESTING_DEPTH) + .maxStringLength(MAX_DOCUMENT_LENGTH) + .build()); Review Comment: [Question] The stream-read constraints are applied only to `YAML_MAPPER`, not to `JSON_MAPPER` -- is that deliberate? `JSON_MAPPER` is built at lines 177-183 with no `StreamReadConstraints`, so it keeps Jackson's defaults (`maxNestingDepth` 1000, `maxStringLength` 20000000) rather than the `MAX_NESTING_DEPTH` 100 / `MAX_DOCUMENT_LENGTH` 4 MiB limits configured here. That matters because `JSON_MAPPER` is what parses caller-supplied content in `parseExtensionData` (lines 614-624), which runs on the raw `data` string of every `custom_extensions` entry in an imported document. A caller can therefore nest well past the 100-level guard inside an extension payload while the outer document stays under it. The Jackson defaults are still bounded, so this is not unbounded recursion -- but if the 100-level limit is deliberate hardening for untrusted input, the same `StreamReadConstraints` should be set on `JSON_MAPPER` too. If the asymmetry is intentional, a one-line comment saying why would help the next reader. Verified by: read `createJsonMapper` (`:177`) and `createYamlMapper` (`:185`) side by side at `da4c250`, and traced every `JSON_MAPPER.readTree` call site -- only `parseExtensionData:619` reads caller-controlled bytes. ########## core/src/main/java/org/apache/gravitino/catalog/SemanticModelOperationDispatcher.java: ########## @@ -138,4 +154,20 @@ private void checkRelationalCatalog(Namespace namespace) { private static NameIdentifier schemaIdentifier(NameIdentifier ident) { return NameIdentifier.of(ident.namespace().levels()); } + + private static void validatePropertyChanges(SemanticModelChange[] changes) { + Map<String, String> upserts = new HashMap<>(); + Map<String, String> deletes = new HashMap<>(); + for (SemanticModelChange change : changes) { + if (change instanceof SemanticModelChange.SetProperty) { + SemanticModelChange.SetProperty setProperty = (SemanticModelChange.SetProperty) change; + upserts.put(setProperty.getProperty(), setProperty.getValue()); + } else if (change instanceof SemanticModelChange.RemoveProperty) { + SemanticModelChange.RemoveProperty removeProperty = + (SemanticModelChange.RemoveProperty) change; + deletes.put(removeProperty.getProperty(), removeProperty.getProperty()); Review Comment: [Nit] `deletes` is a `Map` used as a `Set`, with each key stored as its own value. `validatePropertyForAlter` (`core/src/main/java/org/apache/gravitino/catalog/PropertiesMetadataHelpers.java:109-120`) only ever iterates `deletes.entrySet()` and reads `entry.getKey()` -- the value is never touched. Storing `getProperty()` twice reads like a bug at the call site even though it is harmless. A short comment, or `Maps.toMap(keys, k -> k)`, would make the intent obvious. While here: a change array containing both a `SetProperty` and a `RemoveProperty` for the same key is collected into both maps and validated independently, so the conflict passes validation and the outcome depends on the order the changes are applied downstream. Probably worth rejecting explicitly, or at least confirming the apply order is well defined. Verified by: read `PropertiesMetadataHelpers.validatePropertyForAlter` in full at `da4c250` and confirmed the `deletes` values are unused. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
