mchades commented on code in PR #13571: URL: https://github.com/apache/gravitino/pull/13571#discussion_r4157735104
########## core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java: ########## @@ -0,0 +1,847 @@ +/* + * 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.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.core.util.JsonParserDelegate; +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.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +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 StreamReadConstraints STREAM_READ_CONSTRAINTS = + StreamReadConstraints.builder() + .maxNestingDepth(MAX_NESTING_DEPTH) + .maxStringLength(MAX_DOCUMENT_LENGTH) + .build(); + + 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 ObjectMapper JSON_MAPPER = createJsonMapper(); + private static final ObjectMapper YAML_MAPPER = createYamlMapper(); + + 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(OssieDocument document) { + Objects.requireNonNull(document, "document must not be null"); + 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 OssieDocument exportDocument(SemanticModel semanticModel, OssieFormat 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 == OssieFormat.JSON) { + return OssieDocument.json( + JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(root) + "\n"); + } + return OssieDocument.yaml(YAML_MAPPER.writeValueAsString(root)); + } catch (JsonProcessingException e) { + throw new IllegalSemanticModelException( + e, "Cannot serialize Apache Ossie document: %s", e.getOriginalMessage()); + } + } + + private static ObjectMapper createJsonMapper() { + JsonFactory factory = + JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build(); + factory.setStreamReadConstraints(STREAM_READ_CONSTRAINTS); + return JsonMapper.builder(factory) + .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(STREAM_READ_CONSTRAINTS); + 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(OssieDocument document) { + String content = document.content(); + if (StringUtils.isBlank(content)) { + throw new IllegalSemanticModelException("Apache Ossie document must not be empty"); + } + if (content.length() > MAX_DOCUMENT_LENGTH) { + throw new IllegalSemanticModelException( + "Apache Ossie document exceeds the maximum length of %s characters", MAX_DOCUMENT_LENGTH); + } + + try { + ObjectMapper mapper = document.format() == OssieFormat.JSON ? JSON_MAPPER : YAML_MAPPER; + JsonNode parsed = readDocumentTree(mapper, content); + if (!(parsed instanceof ObjectNode)) { + throw invalid("$", "document root must be an object"); + } + return (ObjectNode) parsed; + } catch (IOException e) { + throw new IllegalSemanticModelException( + e, "Cannot parse Apache Ossie %s: %s", document.format(), originalMessage(e)); + } + } + + private static JsonNode readDocumentTree(ObjectMapper mapper, String content) throws IOException { + // Older YAML parsers do not enforce StreamReadConstraints on nesting depth. + try (JsonParser parser = + new JsonParserDelegate(mapper.createParser(content)) { + @Override + public JsonToken nextToken() throws IOException { + JsonToken token = super.nextToken(); + STREAM_READ_CONSTRAINTS.validateNestingDepth(getParsingContext().getNestingDepth()); + return token; + } + }) { + // Check the whole document, without enabling this on nested DTO deserializers. + return mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(parser); + } + } + + private static SemanticModelCreateRequest toCreateRequest(ObjectNode root) { + validateObject(root, "$", ROOT_PROPERTIES); + validateOptionalText(root, "name", "$"); + validateOptionalText(root, "description", "$"); + 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); + validateOptionalText(dataset, "name", path); + validateOptionalText(dataset, "description", path); + validateOptionalStringArray(dataset, "primary_key", path); + validateOptionalStringArrayArray(dataset, "unique_keys", path); + 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 = parseOssieSource(sourceValue, path + ".source"); + 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 String[] parseOssieSource(String source, String path) { + List<String> parts = new ArrayList<>(3); + int offset = 0; + while (offset < source.length()) { + StringBuilder part = new StringBuilder(); + if (source.charAt(offset) == '`') { + offset++; + boolean closed = false; + while (offset < source.length()) { + char current = source.charAt(offset); + if (current != '`') { + part.append(current); + offset++; + } else if (offset + 1 < source.length() && source.charAt(offset + 1) == '`') { + part.append('`'); + offset += 2; + } else { + closed = true; + offset++; + break; + } + } + if (!closed) { + throw invalid(path, "contains an unterminated quoted segment"); + } + if (offset < source.length() && source.charAt(offset) != '.') { + throw invalid(path, "quoted segments must be separated by dots"); + } + } else { + while (offset < source.length() && source.charAt(offset) != '.') { + char current = source.charAt(offset); + if (current == '`') { + throw invalid(path, "backticks must quote an entire segment"); + } + part.append(current); + offset++; + } + } + + if (StringUtils.isBlank(part)) { + throw invalid(path, "source segments must not be blank"); + } + parts.add(part.toString()); + if (offset < source.length()) { + offset++; + if (offset == source.length()) { + throw invalid(path, "source segments must not be blank"); + } + } + } + + if (parts.size() != 3) { + throw invalid( + path, + "must be a three-part catalog.schema.entity identifier; query sources are not supported"); + } + return parts.toArray(new String[0]); + } + + private static void transformOssieField(ObjectNode field, String path) { + validateObject(field, path, FIELD_PROPERTIES); + validateOptionalText(field, "name", path); + validateOptionalText(field, "label", path); + validateOptionalText(field, "description", path); + validateOptionalText(field, "datatype", path); + transformOssieExpression(field.get("expression"), path + ".expression"); + JsonNode dimension = field.get("dimension"); + if (dimension != null) { + if (!(dimension instanceof ObjectNode)) { + throw invalid(path + ".dimension", "must be an object"); + } + ObjectNode dimensionObject = (ObjectNode) dimension; + validateObject(dimensionObject, path + ".dimension", DIMENSION_PROPERTIES); + validateOptionalBoolean(dimensionObject, "is_time", path + ".dimension"); + rename(dimensionObject, "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); + validateOptionalText(relationship, "name", path); + validateOptionalText(relationship, "from", path); + validateOptionalText(relationship, "to", path); + validateOptionalStringArray(relationship, "from_columns", path); + validateOptionalStringArray(relationship, "to_columns", path); + 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); + validateOptionalText(metric, "name", path); + validateOptionalText(metric, "description", path); + validateOptionalText(metric, "datatype", path); + 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 == null) { + return; + } + if (!(expression instanceof ObjectNode)) { + throw invalid(path, "must be an object"); + } + ObjectNode expressionObject = (ObjectNode) expression; + validateObject(expressionObject, path, EXPRESSION_PROPERTIES); + transformObjectArray( + expressionObject.get("dialects"), + path + ".dialects", + (dialectExpression, dialectPath) -> { + validateObject(dialectExpression, dialectPath, DIALECT_EXPRESSION_PROPERTIES); + validateOptionalText(dialectExpression, "dialect", dialectPath); + validateOptionalText(dialectExpression, "expression", dialectPath); + }); + } + + private static void transformOssieCustomExtensions(@Nullable JsonNode extensions, String path) { + transformObjectArray( + extensions, + path, + (extension, extensionPath) -> { + validateObject(extension, extensionPath, CUSTOM_EXTENSION_PROPERTIES); + validateOptionalText(extension, "vendor_name", extensionPath); + validateOptionalText(extension, "data", extensionPath); + 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"); + } + dataset.put( + "source", + formatOssieSourceSegment(namespace.get(0).textValue()) + + "." + + formatOssieSourceSegment(namespace.get(1).textValue()) + + "." + + formatOssieSourceSegment(name.textValue())); + + transformObjectArray( + dataset.get("fields"), + path + ".fields", + OssieSemanticModelDocumentConverter::transformNativeField); + transformNativeCustomExtensions(dataset.get("customExtensions"), path + ".custom_extensions"); + rename(dataset, "primaryKey", "primary_key"); + rename(dataset, "uniqueKeys", "unique_keys"); + rename(dataset, "aiContext", "ai_context"); + rename(dataset, "customExtensions", "custom_extensions"); + } + + private static String formatOssieSourceSegment(String segment) { + if (!segment.contains(".") && !segment.contains("`")) { + return segment; + } + return "`" + segment.replace("`", "``") + "`"; Review Comment: Added a TODO and tracked the whitespace-only source cases in #12594. Source existence, column, and authorization validation remain in that follow-up, including regression coverage for create, replaceDefinition, and consistency with Ossie import/export. ########## core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java: ########## @@ -0,0 +1,847 @@ +/* + * 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.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.core.util.JsonParserDelegate; +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.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +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 StreamReadConstraints STREAM_READ_CONSTRAINTS = + StreamReadConstraints.builder() + .maxNestingDepth(MAX_NESTING_DEPTH) + .maxStringLength(MAX_DOCUMENT_LENGTH) + .build(); + + 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 ObjectMapper JSON_MAPPER = createJsonMapper(); + private static final ObjectMapper YAML_MAPPER = createYamlMapper(); + + 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(OssieDocument document) { + Objects.requireNonNull(document, "document must not be null"); + 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 OssieDocument exportDocument(SemanticModel semanticModel, OssieFormat 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 == OssieFormat.JSON) { + return OssieDocument.json( + JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(root) + "\n"); + } + return OssieDocument.yaml(YAML_MAPPER.writeValueAsString(root)); + } catch (JsonProcessingException e) { + throw new IllegalSemanticModelException( + e, "Cannot serialize Apache Ossie document: %s", e.getOriginalMessage()); + } + } + + private static ObjectMapper createJsonMapper() { + JsonFactory factory = + JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build(); + factory.setStreamReadConstraints(STREAM_READ_CONSTRAINTS); + return JsonMapper.builder(factory) + .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(STREAM_READ_CONSTRAINTS); + 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(OssieDocument document) { + String content = document.content(); + if (StringUtils.isBlank(content)) { + throw new IllegalSemanticModelException("Apache Ossie document must not be empty"); + } + if (content.length() > MAX_DOCUMENT_LENGTH) { + throw new IllegalSemanticModelException( + "Apache Ossie document exceeds the maximum length of %s characters", MAX_DOCUMENT_LENGTH); + } + + try { + ObjectMapper mapper = document.format() == OssieFormat.JSON ? JSON_MAPPER : YAML_MAPPER; + JsonNode parsed = readDocumentTree(mapper, content); + if (!(parsed instanceof ObjectNode)) { + throw invalid("$", "document root must be an object"); + } + return (ObjectNode) parsed; + } catch (IOException e) { + throw new IllegalSemanticModelException( + e, "Cannot parse Apache Ossie %s: %s", document.format(), originalMessage(e)); + } + } + + private static JsonNode readDocumentTree(ObjectMapper mapper, String content) throws IOException { + // Older YAML parsers do not enforce StreamReadConstraints on nesting depth. + try (JsonParser parser = + new JsonParserDelegate(mapper.createParser(content)) { + @Override + public JsonToken nextToken() throws IOException { + JsonToken token = super.nextToken(); + STREAM_READ_CONSTRAINTS.validateNestingDepth(getParsingContext().getNestingDepth()); Review Comment: Removed the converter-specific size and nesting limits and the Java/Python AIContext depth caps, along with the corresponding limit tests. Parser defaults and structural validation remain in place. ########## server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java: ########## @@ -162,6 +164,63 @@ 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. + * @param headers The request headers specifying the document's media type. + * @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, + OSSIE_TEXT_YAML_MEDIA_TYPE + }) + @Produces(VND_GRAVITINO_V1_JSON) + @Timed(name = "import-ossie-semantic-model." + MetricNames.HTTP_PROCESS_DURATION, absolute = true) + @ResponseMetered(name = "import-ossie-semantic-model", absolute = true) + public Response importOssieSemanticModel( + @PathParam("metalake") String metalake, + @PathParam("catalog") String catalog, + @PathParam("schema") String schema, + String document, + @Context HttpHeaders headers) { + LOG.info( + "Received import Apache Ossie Semantic Model request for schema: {}.{}.{}", + metalake, + catalog, + schema); + try { + return Utils.doAs( + httpRequest, + () -> { + OssieDocument ossieDocument = + MediaType.APPLICATION_JSON_TYPE.isCompatible(headers.getMediaType()) + ? OssieDocument.json(document) + : OssieDocument.yaml(document); Review Comment: Documented that REST import accepts raw document text, with `Content-Type` selecting JSON or YAML and YAML as the default when the header is absent. The follow-up Java client must send raw content rather than JSON-encoding the document string. ########## core/src/main/java/org/apache/gravitino/catalog/SemanticModelDispatcher.java: ########## @@ -18,7 +18,42 @@ */ package org.apache.gravitino.catalog; +import org.apache.gravitino.NameIdentifier; +import org.apache.gravitino.Namespace; +import org.apache.gravitino.dto.requests.SemanticModelCreateRequest; +import org.apache.gravitino.semantic.OssieDocument; +import org.apache.gravitino.semantic.OssieFormat; +import org.apache.gravitino.semantic.OssieSemanticModelDocumentConverter; +import org.apache.gravitino.semantic.SemanticModel; import org.apache.gravitino.semantic.SemanticModelCatalog; /** A dispatcher specialization for schema-scoped Semantic Model operations. */ -public interface SemanticModelDispatcher extends SemanticModelCatalog {} +public interface SemanticModelDispatcher extends SemanticModelCatalog { + + /** + * {@inheritDoc} + * + * <p>Delegates to this dispatcher's native create operation so that the document follows the same + * normalization and validation chain as a structured create request. + */ + @Override + default SemanticModel importOssieSemanticModel(Namespace namespace, OssieDocument document) { + SemanticModelCreateRequest request = + OssieSemanticModelDocumentConverter.importDocument(document); + return createSemanticModel( + NameIdentifier.of(namespace, request.getName()), + request.getComment(), + request.toDefinition(), Review Comment: Removed the unused `toDefinition()` call from the converter. `request.validate()` still validates the nested DTOs, and the dispatcher performs the single conversion used to create the model. -- 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]
