jerryshao commented on code in PR #13571:
URL: https://github.com/apache/gravitino/pull/13571#discussion_r4214554679
##########
api/src/main/java/org/apache/gravitino/semantic/AIContextObject.java:
##########
@@ -370,12 +359,7 @@ private static Number canonicalizeJsonNumber(Number value,
String path) {
}
private static void enterContainer(
- Object value, String path, IdentityHashMap<Object, Boolean> visiting,
int containerDepth) {
- Preconditions.checkArgument(
- containerDepth <= MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH,
- "Additional property %s exceeds maximum nesting depth of %s",
- path,
- MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH);
+ Object value, String path, IdentityHashMap<Object, Boolean> visiting) {
Review Comment:
With the depth check gone from `enterContainer`, `immutableJsonValue` /
`immutableJsonMap` / `immutableJsonList` / `immutableJsonArray` are unbounded
mutual recursion, and cycle detection doesn't help for deep-but-acyclic input.
The "parser defaults, cycle detection, and JSON-value validation remain in
place" rationale holds for parsed input — Jackson's `maxNestingDepth` defaults
to 1000 and SnakeYAML's `nestingDepthLimit` to 50 — but
`AIContextObject.builder().withAdditionalProperties(...)` is a public
programmatic entry point with no parser in front of it. A Java client or
embedded user that builds a few hundred thousand levels of `Map.of("nested",
prev)` and calls `build()` gets a `StackOverflowError`, which is an `Error`
rather than the `IllegalArgumentException` the `build()` Javadoc promises, and
which `ExceptionHandlers` (catching `Exception`) will not map to a 400.
If a hard cap is unwanted, an explicit stack instead of recursion would
work, or a much looser bound aligned with the parser default (1000) would still
keep the failure mode a typed exception.
##########
core/src/main/java/org/apache/gravitino/catalog/SemanticModelOperationDispatcher.java:
##########
@@ -94,10 +103,16 @@ public SemanticModel createSemanticModel(
throws NoSuchSchemaException, SemanticModelAlreadyExistsException,
IllegalSemanticModelException {
Preconditions.checkArgument(properties != null, "Properties must not be
null");
+ validatePropertyForCreate(PROPERTIES_METADATA, properties);
+ Map<String, String> effectiveProperties = new HashMap<>(properties);
+ effectiveProperties.putIfAbsent(
Review Comment:
Persisting the `ossie-version` default looks redundant: the export path
already falls back through
`OssieSemanticModelDocumentConverter.ossieVersion()`, which does
`properties.getOrDefault(PROPERTY_OSSIE_VERSION, DEFAULT_OSSIE_VERSION)`. So
this write doesn't enable anything — it only adds an observable side effect.
The side effect is that one logical default now has three states:
1. Models created before this PR have no `ossie-version` at all (nothing
backfills them).
2. Models created after it carry `ossie-version=0.2.0.dev0` even when the
caller passed no properties —
`testCreateThenLoadWithoutCatalogBackedSourceValidation` locks this in, and
it's why `testListAlterAndDropLifecycleWithDefinitionValidation` had to change
from `assertEquals(Map.of("owner", "analytics"), ...)`.
3. The property is neither immutable nor reserved, so
`testAlterCanRemoveOssieVersion` shows it can be deleted, putting the model
back in state 1.
For any client that compares the properties it sent against the properties
it reads back, or that passes `properties` through as user data, this is a
breaking change to the native create API. Could the `putIfAbsent` be dropped
and the existing `getOrDefault` relied on? If it really should be persisted, it
would be worth saying so in the user-facing-change section and considering
making it non-deletable so the three states collapse into one.
##########
server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java:
##########
@@ -302,4 +365,86 @@ public Response dropSemanticModel(
OperationType.DROP, semanticModel, schema, e);
}
}
+
+ /**
+ * Exports a Semantic Model as a standalone Apache Ossie YAML or JSON
document.
+ *
+ * @param metalake The metalake name.
+ * @param catalog The catalog name.
+ * @param schema The schema name.
+ * @param semanticModel The Semantic Model name.
+ * @param format The output format, either {@code yaml} or {@code json}.
+ * @return A response containing the serialized Ossie document.
+ */
+ @GET
+ @Path("{semanticModel}/ossie")
Review Comment:
This is the only method in the resource without `@Produces`, and there's no
class-level `@Produces` either, so JAX-RS treats it as `*/*` and the response
type is decided entirely by `Response.ok(content, mediaType)`.
A client sending `GET .../ossie?format=yaml` with `Accept: application/json`
(or the reverse) therefore gets 200 plus `Content-Type: application/yaml`
instead of a 406, and a client that parses strictly by its own `Accept`
receives a body it can't read. The tests use `.request("application/yaml")`, so
this isn't exercised.
Declaring `@Produces({OSSIE_YAML_MEDIA_TYPE, MediaType.APPLICATION_JSON})`
would give the correct 406 and also leave room to negotiate via `Accept`
instead of `?format=` later.
##########
clients/client-python/gravitino/api/semantic/ai_context_object.py:
##########
@@ -146,57 +142,44 @@ def _normalize_json_value(
return value
if isinstance(value, dict):
- return _normalize_json_dict(value, path, visiting, container_depth + 1)
+ return _normalize_json_dict(value, path, visiting)
if isinstance(value, (list, tuple)):
- return _normalize_json_list(value, path, visiting, container_depth + 1)
+ return _normalize_json_list(value, path, visiting)
raise IllegalArgumentException(
f"Additional property {path} has non-JSON-compatible value type: "
f"{type(value).__name__}"
)
-def _normalize_json_dict(
- value: dict, path: str, visiting: set[int], container_depth: int
-) -> dict[str, Any]:
- _enter_container(value, path, visiting, container_depth)
+def _normalize_json_dict(value: dict, path: str, visiting: set[int]) ->
dict[str, Any]:
+ _enter_container(value, path, visiting)
try:
normalized = {}
for key, item in value.items():
Precondition.check_argument(
isinstance(key, str),
f"Additional property {path} contains a map key that is not a
string",
)
- normalized[key] = _normalize_json_value(
- item, f"{path}.{key}", visiting, container_depth
- )
+ normalized[key] = _normalize_json_value(item, f"{path}.{key}",
visiting)
return normalized
finally:
visiting.discard(id(value))
-def _normalize_json_list(
- value, path: str, visiting: set[int], container_depth: int
-) -> list[Any]:
- _enter_container(value, path, visiting, container_depth)
+def _normalize_json_list(value, path: str, visiting: set[int]) -> list[Any]:
+ _enter_container(value, path, visiting)
try:
return [
- _normalize_json_value(item, f"{path}[{index}]", visiting,
container_depth)
+ _normalize_json_value(item, f"{path}[{index}]", visiting)
for index, item in enumerate(value)
]
finally:
visiting.discard(id(value))
-def _enter_container(
- value: Any, path: str, visiting: set[int], container_depth: int
-) -> None:
- Precondition.check_argument(
- container_depth <= MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH,
- f"Additional property {path} exceeds maximum nesting depth of "
- f"{MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH}",
- )
+def _enter_container(value: Any, path: str, visiting: set[int]) -> None:
Review Comment:
Same concern as the Java side: `_normalize_json_value` /
`_normalize_json_dict` / `_normalize_json_list` are still mutual recursion with
the depth assertion removed.
`AIContextObject(additional_properties={"nested": value})` where `value` is
a programmatically built ~1500-deep list raises `RecursionError` (Python's
default `recursionlimit` is 1000 and each level costs several frames).
`RecursionError` inherits from `RuntimeError`, not from `ValueError` /
`IllegalArgumentException`, so callers that catch the documented argument error
are bypassed entirely. The deleted
`test_ai_context_object_rejects_deeply_nested_additional_properties` was
exactly this coverage.
##########
core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java:
##########
@@ -0,0 +1,795 @@
+/*
+ * 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.CustomExtension.GRAVITINO_PROPERTIES_VENDOR;
+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.JsonProcessingException;
+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.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 {
+
+ 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();
+ 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();
+ 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");
+ }
+ try {
+ ObjectMapper mapper = document.format() == OssieFormat.JSON ?
JSON_MAPPER : YAML_MAPPER;
+ // Check the whole document, without enabling this on nested DTO
deserializers.
+ JsonNode parsed =
+
mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(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 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();
+ 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) {
Review Comment:
`formatOssieSourceSegment` only quotes a segment that contains `.` or a
backtick, so a blank segment is emitted verbatim — but the export
self-validation at the end of `exportDocument` runs the result back through
`parseOssieSource`, which rejects blank segments via `StringUtils.isBlank`.
That path is reachable: `NameIdentifier`'s constructor only checks `name !=
null && !name.isEmpty()`, and `SemanticModelValidator.validateSource` only
checks `namespace().length() != 2`. So a dataset whose source is
`NameIdentifier.of(Namespace.of(" ", "mart"), "orders")` is created
successfully; exporting it produces `" .mart.orders"` and then fails its own
validation with `source segments must not be blank`, making that model
un-exportable forever.
Your TODO(#12594) in `SemanticModelValidator` already names whitespace-only
source segments, but this looks cheap to close here: either quote blank
segments too (and skip the blank check for quoted segments in
`parseOssieSource`), or reject blank segments in `validateSource` so both sides
agree.
##########
core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java:
##########
@@ -0,0 +1,795 @@
+/*
+ * 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.CustomExtension.GRAVITINO_PROPERTIES_VENDOR;
+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.JsonProcessingException;
+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.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 {
+
+ 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();
+ 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();
+ 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");
+ }
+ try {
+ ObjectMapper mapper = document.format() == OssieFormat.JSON ?
JSON_MAPPER : YAML_MAPPER;
+ // Check the whole document, without enabling this on nested DTO
deserializers.
+ JsonNode parsed =
+
mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(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 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();
+ 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("`", "``") + "`";
+ }
+
+ private static void transformNativeField(ObjectNode field, String path) {
+ JsonNode dimension = field.get("dimension");
+ if (dimension instanceof ObjectNode) {
+ rename((ObjectNode) dimension, "isTime", "is_time");
+ }
+ transformNativeCustomExtensions(field.get("customExtensions"), path +
".custom_extensions");
+ rename(field, "aiContext", "ai_context");
+ rename(field, "customExtensions", "custom_extensions");
+ }
+
+ private static void transformNativeRelationship(ObjectNode relationship,
String path) {
+ transformNativeCustomExtensions(
+ relationship.get("customExtensions"), path + ".custom_extensions");
+ rename(relationship, "fromColumns", "from_columns");
+ rename(relationship, "toColumns", "to_columns");
+ rename(relationship, "aiContext", "ai_context");
+ rename(relationship, "customExtensions", "custom_extensions");
+ }
+
+ private static void transformNativeMetric(ObjectNode metric, String path) {
+ transformNativeCustomExtensions(metric.get("customExtensions"), path +
".custom_extensions");
+ rename(metric, "aiContext", "ai_context");
+ rename(metric, "customExtensions", "custom_extensions");
+ }
+
+ private static void transformNativeCustomExtensions(@Nullable JsonNode
extensions, String path) {
+ transformObjectArray(
+ extensions,
+ path,
+ (extension, extensionPath) -> rename(extension, "vendorName",
"vendor_name"));
+ }
+
+ private static void stashProperties(ObjectNode root, Map<String, String>
properties) {
+ Map<String, String> interchangeProperties = new LinkedHashMap<>();
+ if (properties != null) {
+ properties.forEach(
+ (key, value) -> {
+ if (!PROPERTY_OSSIE_VERSION.equals(key)) {
+ interchangeProperties.put(key, value);
+ }
+ });
+ }
+
+ JsonNode existingExtensions = root.get("custom_extensions");
+ ArrayNode extensions;
+ if (existingExtensions == null) {
+ if (interchangeProperties.isEmpty()) {
+ return;
+ }
+ extensions = root.putArray("custom_extensions");
+ } else if (existingExtensions instanceof ArrayNode) {
+ extensions = (ArrayNode) existingExtensions;
+ } else {
+ throw invalid("$.custom_extensions", "must be an array");
+ }
+ ensureNoPropertiesExtension(extensions);
Review Comment:
`SemanticModelValidator.validateRootCustomExtensions` only runs on create
and replaceDefinition and doesn't revalidate stored entities, but
`ensureNoPropertiesExtension` here rejects the vendor unconditionally on every
export.
So a model created before this PR whose root `customExtensions` happens to
use `GRAVITINO_PROPERTIES` as its `vendorName` — perfectly legal at the time —
will fail `GET .../semantic-models/{model}/ossie` with 400
`'GRAVITINO_PROPERTIES' is reserved for Gravitino properties`, permanently,
with no hint that the fix is to rename that extension via `replaceDefinition`.
It's recoverable, but the error text points at the document path rather than at
the model definition, so it reads like a server bug.
Worth either rewording the message to name the remedy, or calling the new
reserved vendor out as a user-facing change for existing data.
--
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]