This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch codex/issue-13540-ossie-import-export
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to
refs/heads/codex/issue-13540-ossie-import-export by this push:
new baf49af650 fix(semantic-model): address Ossie import/export review
comments
baf49af650 is described below
commit baf49af6508721038b030db04c76d5e90dbd4989
Author: mchades <[email protected]>
AuthorDate: Fri Oct 2 00:12:27 2026 +0800
fix(semantic-model): address Ossie import/export review comments
---
.../apache/gravitino/semantic/AIContextObject.java | 52 ++----
.../apache/gravitino/semantic/CustomExtension.java | 6 +
.../gravitino/semantic/SemanticModelCatalog.java | 13 ++
.../semantic/TestSemanticModelSupportingTypes.java | 31 +---
.../gravitino/api/semantic/ai_context_object.py | 39 ++---
.../test_semantic_model_supporting_types.py | 13 +-
.../gravitino/catalog/SemanticModelValidator.java | 22 ++-
.../OssieSemanticModelDocumentConverter.java | 122 ++++---------
.../TestSemanticModelOperationDispatcher.java | 64 ++++++-
.../catalog/TestSemanticModelValidator.java | 45 +++++
.../TestOssieSemanticModelDocumentConverter.java | 193 +++++++++++++++------
.../server/web/rest/SemanticModelOperations.java | 4 +
.../web/rest/TestSemanticModelOperations.java | 4 +-
13 files changed, 364 insertions(+), 244 deletions(-)
diff --git
a/api/src/main/java/org/apache/gravitino/semantic/AIContextObject.java
b/api/src/main/java/org/apache/gravitino/semantic/AIContextObject.java
index 6e9c156786..c804d509e5 100644
--- a/api/src/main/java/org/apache/gravitino/semantic/AIContextObject.java
+++ b/api/src/main/java/org/apache/gravitino/semantic/AIContextObject.java
@@ -37,8 +37,6 @@ import org.apache.gravitino.annotation.Evolving;
@Evolving
public final class AIContextObject {
- static final int MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH = 100;
-
@Nullable private final String instructions;
@Nullable private final String[] synonyms;
@Nullable private final String[] examples;
@@ -206,9 +204,8 @@ public final class AIContextObject {
* <p>Values may be null, strings, booleans, JSON-compatible numbers, maps
with string keys,
* lists, or Java arrays. Integral numbers are normalized to {@link
BigInteger}, and decimal
* numbers are normalized to {@link BigDecimal} so their value semantics
remain stable across
- * JSON round trips. Nested maps, lists, and arrays may be at most {@value
- * #MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH} levels deep. Property names
must not duplicate {@code
- * instructions}, {@code synonyms}, or {@code examples}.
+ * JSON round trips. Property names must not duplicate {@code
instructions}, {@code synonyms},
+ * or {@code examples}.
*
* @param additionalProperties The additional properties.
* @return This builder.
@@ -224,7 +221,7 @@ public final class AIContextObject {
* @return The immutable structured AI context.
* @throws IllegalArgumentException If a string array contains null, the
additional properties
* are null, a property duplicates a standard field, or a property
value is not
- * JSON-compatible or exceeds the supported nesting depth.
+ * JSON-compatible or contains a cyclic reference.
*/
public AIContextObject build() {
SemanticModelDefinition.validateNoNullElements("synonyms", synonyms);
@@ -245,16 +242,13 @@ public final class AIContextObject {
!isStandardProperty(name),
"additional property must not duplicate standard property: %s",
name);
- result.put(name, immutableJsonValue(entry.getValue(), name, visiting,
0));
+ result.put(name, immutableJsonValue(entry.getValue(), name, visiting));
}
return Collections.unmodifiableMap(result);
}
private static Object immutableJsonValue(
- @Nullable Object value,
- String path,
- IdentityHashMap<Object, Boolean> visiting,
- int containerDepth) {
+ @Nullable Object value, String path, IdentityHashMap<Object, Boolean>
visiting) {
if (value == null || value instanceof String || value instanceof Boolean) {
return value;
}
@@ -262,13 +256,13 @@ public final class AIContextObject {
return canonicalizeJsonNumber((Number) value, path);
}
if (value instanceof Map) {
- return immutableJsonMap((Map<?, ?>) value, path, visiting,
containerDepth + 1);
+ return immutableJsonMap((Map<?, ?>) value, path, visiting);
}
if (value instanceof List) {
- return immutableJsonList((List<?>) value, path, visiting, containerDepth
+ 1);
+ return immutableJsonList((List<?>) value, path, visiting);
}
if (value.getClass().isArray()) {
- return immutableJsonArray(value, path, visiting, containerDepth + 1);
+ return immutableJsonArray(value, path, visiting);
}
throw new IllegalArgumentException(
String.format(
@@ -277,8 +271,8 @@ public final class AIContextObject {
}
private static Map<String, Object> immutableJsonMap(
- Map<?, ?> value, String path, IdentityHashMap<Object, Boolean> visiting,
int containerDepth) {
- enterContainer(value, path, visiting, containerDepth);
+ Map<?, ?> value, String path, IdentityHashMap<Object, Boolean> visiting)
{
+ enterContainer(value, path, visiting);
try {
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : value.entrySet()) {
@@ -287,8 +281,7 @@ public final class AIContextObject {
"Additional property %s contains a map key that is not a string",
path);
String key = (String) entry.getKey();
- result.put(
- key, immutableJsonValue(entry.getValue(), path + "." + key,
visiting, containerDepth));
+ result.put(key, immutableJsonValue(entry.getValue(), path + "." + key,
visiting));
}
return Collections.unmodifiableMap(result);
} finally {
@@ -297,14 +290,12 @@ public final class AIContextObject {
}
private static List<Object> immutableJsonList(
- List<?> value, String path, IdentityHashMap<Object, Boolean> visiting,
int containerDepth) {
- enterContainer(value, path, visiting, containerDepth);
+ List<?> value, String path, IdentityHashMap<Object, Boolean> visiting) {
+ enterContainer(value, path, visiting);
try {
List<Object> result = new ArrayList<>(value.size());
for (int index = 0; index < value.size(); index++) {
- result.add(
- immutableJsonValue(
- value.get(index), path + "[" + index + "]", visiting,
containerDepth));
+ result.add(immutableJsonValue(value.get(index), path + "[" + index +
"]", visiting));
}
return Collections.unmodifiableList(result);
} finally {
@@ -313,15 +304,13 @@ public final class AIContextObject {
}
private static List<Object> immutableJsonArray(
- Object value, String path, IdentityHashMap<Object, Boolean> visiting,
int containerDepth) {
- enterContainer(value, path, visiting, containerDepth);
+ Object value, String path, IdentityHashMap<Object, Boolean> visiting) {
+ enterContainer(value, path, visiting);
try {
int length = Array.getLength(value);
List<Object> result = new ArrayList<>(length);
for (int index = 0; index < length; index++) {
- result.add(
- immutableJsonValue(
- Array.get(value, index), path + "[" + index + "]", visiting,
containerDepth));
+ result.add(immutableJsonValue(Array.get(value, index), path + "[" +
index + "]", visiting));
}
return Collections.unmodifiableList(result);
} finally {
@@ -370,12 +359,7 @@ public final class AIContextObject {
}
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) {
Preconditions.checkArgument(
!visiting.containsKey(value), "Additional property %s contains a
cyclic value", path);
visiting.put(value, Boolean.TRUE);
diff --git
a/api/src/main/java/org/apache/gravitino/semantic/CustomExtension.java
b/api/src/main/java/org/apache/gravitino/semantic/CustomExtension.java
index 1b0b8a8e6c..828f62923b 100644
--- a/api/src/main/java/org/apache/gravitino/semantic/CustomExtension.java
+++ b/api/src/main/java/org/apache/gravitino/semantic/CustomExtension.java
@@ -27,6 +27,12 @@ import org.apache.gravitino.annotation.Evolving;
@Evolving
public final class CustomExtension {
+ /**
+ * The root-level extension vendor reserved for carrying Gravitino
properties in Ossie documents.
+ * Native Semantic Model definitions must not use this vendor in their root
custom extensions.
+ */
+ public static final String GRAVITINO_PROPERTIES_VENDOR =
"GRAVITINO_PROPERTIES";
+
private final String vendorName;
private final String data;
diff --git
a/api/src/main/java/org/apache/gravitino/semantic/SemanticModelCatalog.java
b/api/src/main/java/org/apache/gravitino/semantic/SemanticModelCatalog.java
index 577f5c4099..c16b5f309f 100644
--- a/api/src/main/java/org/apache/gravitino/semantic/SemanticModelCatalog.java
+++ b/api/src/main/java/org/apache/gravitino/semantic/SemanticModelCatalog.java
@@ -92,6 +92,15 @@ public interface SemanticModelCatalog {
* #createSemanticModel}; it does not replace an existing model. The
document is parsed according
* to its declared format, without falling back to another format.
*
+ * <p>REST clients send {@link OssieDocument#content()} as the raw request
body, without
+ * JSON-encoding the document string again. The {@code Content-Type} header
selects the parser:
+ * {@code application/json} for JSON, or {@code application/yaml}, {@code
application/x-yaml}, or
+ * {@code text/yaml} for YAML. If the header is absent, the REST endpoint
defaults to YAML.
+ *
+ * <p>A root {@link CustomExtension#GRAVITINO_PROPERTIES_VENDOR} extension
is consumed as model
+ * properties before creation. Its data must encode a JSON object with
string values, and at most
+ * one such extension is allowed. Other custom extensions are preserved.
+ *
* @param namespace The destination schema namespace.
* @param document The standalone Ossie document and its serialization
format.
* @return The created Semantic Model.
@@ -109,6 +118,10 @@ public interface SemanticModelCatalog {
/**
* Exports a Semantic Model as a standalone Apache Ossie document.
*
+ * <p>The {@link SemanticModel#PROPERTY_OSSIE_VERSION} property supplies the
document version.
+ * Other properties, if present, are serialized as a JSON object in the data
of a root {@link
+ * CustomExtension#GRAVITINO_PROPERTIES_VENDOR} extension.
+ *
* @param ident The Semantic Model identifier.
* @param format The requested serialization format.
* @return The serialized Ossie document and its format.
diff --git
a/api/src/test/java/org/apache/gravitino/semantic/TestSemanticModelSupportingTypes.java
b/api/src/test/java/org/apache/gravitino/semantic/TestSemanticModelSupportingTypes.java
index 5389f214e7..04307ebc2c 100644
---
a/api/src/test/java/org/apache/gravitino/semantic/TestSemanticModelSupportingTypes.java
+++
b/api/src/test/java/org/apache/gravitino/semantic/TestSemanticModelSupportingTypes.java
@@ -180,31 +180,6 @@ public class TestSemanticModelSupportingTypes {
assertEquals(equivalentContext.hashCode(),
equivalentLongContext.hashCode());
}
- @Test
- public void testAIContextAdditionalPropertyBounds() {
- Object maximumDepthValue = "value";
- for (int depth = 0; depth <
AIContextObject.MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH; depth++) {
- maximumDepthValue = Map.of("nested", maximumDepthValue);
- }
- AIContextObject.builder()
- .withAdditionalProperties(Map.of("maximumDepth", maximumDepthValue))
- .build();
-
- Object excessiveDepthValue = Map.of("nested", maximumDepthValue);
- assertThrows(
- IllegalArgumentException.class,
- () ->
- AIContextObject.builder()
- .withAdditionalProperties(Map.of("excessiveDepth",
excessiveDepthValue))
- .build());
- assertThrows(
- IllegalArgumentException.class,
- () ->
- AIContextObject.builder()
- .withAdditionalProperties(Map.of("unsupportedNumber", new
AtomicInteger(1)))
- .build());
- }
-
@Test
public void testSupportingTypeValidation() {
assertThrows(IllegalArgumentException.class, () ->
DialectExpression.builder().build());
@@ -244,6 +219,12 @@ public class TestSemanticModelSupportingTypes {
AIContextObject.builder()
.withAdditionalProperties(Map.of("unsupported", new Object()))
.build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ AIContextObject.builder()
+ .withAdditionalProperties(Map.of("unsupportedNumber", new
AtomicInteger(1)))
+ .build());
assertThrows(
IllegalArgumentException.class,
() ->
diff --git a/clients/client-python/gravitino/api/semantic/ai_context_object.py
b/clients/client-python/gravitino/api/semantic/ai_context_object.py
index bee224a78a..b269b0842d 100644
--- a/clients/client-python/gravitino/api/semantic/ai_context_object.py
+++ b/clients/client-python/gravitino/api/semantic/ai_context_object.py
@@ -23,8 +23,6 @@ from typing import Any, Final, Optional
from gravitino.exceptions.base import IllegalArgumentException
from gravitino.utils.precondition import Precondition
-MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH: Final[int] = 100
-
_STANDARD_PROPERTIES: Final[frozenset] = frozenset(
{"instructions", "synonyms", "examples"}
)
@@ -121,13 +119,11 @@ def _normalize_additional_properties(
name not in _STANDARD_PROPERTIES,
f"additional property must not duplicate standard property:
{name}",
)
- normalized[name] = _normalize_json_value(value, name, set(), 0)
+ normalized[name] = _normalize_json_value(value, name, set())
return normalized
-def _normalize_json_value(
- value: Any, path: str, visiting: set[int], container_depth: int
-) -> Any:
+def _normalize_json_value(value: Any, path: str, visiting: set[int]) -> Any:
if value is None or isinstance(value, (str, bool)):
return value
@@ -146,10 +142,10 @@ 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: "
@@ -157,10 +153,8 @@ def _normalize_json_value(
)
-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():
@@ -168,35 +162,24 @@ def _normalize_json_dict(
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:
Precondition.check_argument(
id(value) not in visiting,
f"Additional property {path} contains a cyclic value",
diff --git
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_supporting_types.py
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_supporting_types.py
index 22fe689033..b26e0648a1 100644
---
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_supporting_types.py
+++
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_supporting_types.py
@@ -18,10 +18,7 @@
import unittest
from gravitino.api.semantic.ai_context import AIContext
-from gravitino.api.semantic.ai_context_object import (
- MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH,
- AIContextObject,
-)
+from gravitino.api.semantic.ai_context_object import AIContextObject
from gravitino.api.semantic.custom_extension import CustomExtension
from gravitino.api.semantic.data_type import DataType
from gravitino.api.semantic.dialect_expression import DialectExpression
@@ -250,14 +247,6 @@ class TestSemanticModelSupportingTypes(unittest.TestCase):
):
AIContextObject(additional_properties={"nested": cyclic})
- def
test_ai_context_object_rejects_deeply_nested_additional_properties(self):
- value = "leaf"
- for _ in range(MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH + 1):
- value = [value]
-
- with self.assertRaisesRegex(ValueError, "exceeds maximum nesting depth
of 100"):
- AIContextObject(additional_properties={"nested": value})
-
def test_ai_context_object_rejects_none_elements(self):
with self.assertRaisesRegex(ValueError, r"synonyms\[1\] must be a
string"):
AIContextObject(synonyms=["sales", None])
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/SemanticModelValidator.java
b/core/src/main/java/org/apache/gravitino/catalog/SemanticModelValidator.java
index 211585b6ae..bebda80bb1 100644
---
a/core/src/main/java/org/apache/gravitino/catalog/SemanticModelValidator.java
+++
b/core/src/main/java/org/apache/gravitino/catalog/SemanticModelValidator.java
@@ -18,11 +18,14 @@
*/
package org.apache.gravitino.catalog;
+import static
org.apache.gravitino.semantic.CustomExtension.GRAVITINO_PROPERTIES_VENDOR;
+
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Nullable;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.exceptions.IllegalSemanticModelException;
+import org.apache.gravitino.semantic.CustomExtension;
import org.apache.gravitino.semantic.Dataset;
import org.apache.gravitino.semantic.Field;
import org.apache.gravitino.semantic.Metric;
@@ -42,7 +45,7 @@ import org.apache.gravitino.semantic.SemanticModelDefinition;
* <p>The API value-object builders enforce value-level invariants, including
required values, array
* element validity, relationship column shapes, and expression dialect
uniqueness. This validator
* handles constraints that span value objects: name uniqueness, relationship
endpoint resolution,
- * and Semantic Model source identifier shape.
+ * Semantic Model source identifier shape, and the reserved root properties
extension vendor.
*
* <p>Validation is deterministic and performs no catalog I/O. Catalog-backed
source validation,
* including existence, columns, and authorization, must be completed by the
caller before this
@@ -54,7 +57,8 @@ final class SemanticModelValidator {
private SemanticModelValidator() {}
// TODO(#12594): Validate source existence, columns, and authorization in
the caller before
- // invoking this definition-only validator.
+ // invoking this definition-only validator. Cover whitespace-only source
segments in regression
+ // tests for create and replaceDefinition, and verify consistency with Ossie
import/export.
static void validateDefinition(@Nullable SemanticModelDefinition definition)
{
if (definition == null) {
throw invalid("$", "definition must not be null");
@@ -68,6 +72,20 @@ final class SemanticModelValidator {
validateRelationships(definition.relationships(), datasetNames);
validateMetrics(definition.metrics());
+ validateRootCustomExtensions(definition.customExtensions());
+ }
+
+ private static void validateRootCustomExtensions(@Nullable CustomExtension[]
extensions) {
+ if (extensions == null) {
+ return;
+ }
+ for (int index = 0; index < extensions.length; index++) {
+ if (GRAVITINO_PROPERTIES_VENDOR.equals(extensions[index].vendorName())) {
+ throw invalid(
+ "customExtensions[" + index + "].vendorName",
+ "'" + GRAVITINO_PROPERTIES_VENDOR + "' is reserved for Gravitino
properties");
+ }
+ }
}
private static void validateDataset(
diff --git
a/core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java
b/core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java
index ffeb0529e6..e13330844a 100644
---
a/core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java
+++
b/core/src/main/java/org/apache/gravitino/semantic/OssieSemanticModelDocumentConverter.java
@@ -18,17 +18,14 @@
*/
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.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;
@@ -54,19 +51,6 @@ 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",
@@ -170,7 +154,6 @@ public final class OssieSemanticModelDocumentConverter {
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)
@@ -184,7 +167,6 @@ public final class OssieSemanticModelDocumentConverter {
.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)
@@ -197,14 +179,11 @@ public final class OssieSemanticModelDocumentConverter {
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);
+ // 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");
}
@@ -215,22 +194,6 @@ public final class OssieSemanticModelDocumentConverter {
}
}
- 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", "$");
@@ -265,7 +228,6 @@ public final class OssieSemanticModelDocumentConverter {
SemanticModelCreateRequest request =
JSON_MAPPER.treeToValue(requestNode,
SemanticModelCreateRequest.class);
request.validate();
- request.toDefinition();
return request;
} catch (JsonProcessingException | IllegalArgumentException e) {
throw new IllegalSemanticModelException(
@@ -615,20 +577,15 @@ public final class OssieSemanticModelDocumentConverter {
} else {
throw invalid("$.custom_extensions", "must be an array");
}
- ensureNoReservedMarker(extensions);
+ ensureNoPropertiesExtension(extensions);
if (interchangeProperties.isEmpty()) {
return;
}
- ObjectNode payload = JSON_MAPPER.createObjectNode();
- ObjectNode marker = payload.putObject(INTERCHANGE_MARKER);
- marker.put("version", INTERCHANGE_MARKER_VERSION);
- marker.set("properties", JSON_MAPPER.valueToTree(interchangeProperties));
-
ObjectNode extension = extensions.addObject();
- extension.put("vendor_name", GRAVITINO_VENDOR);
- extension.put("data", writeExtensionData(payload));
+ extension.put("vendor_name", GRAVITINO_PROPERTIES_VENDOR);
+ extension.put("data",
writeExtensionData(JSON_MAPPER.valueToTree(interchangeProperties)));
}
private static Map<String, String> extractProperties(ObjectNode root) {
@@ -640,52 +597,41 @@ public final class OssieSemanticModelDocumentConverter {
ArrayNode extensions = (ArrayNode) extensionsNode;
Map<String, String> properties = new LinkedHashMap<>();
boolean found = false;
+ int index = 0;
Iterator<JsonNode> iterator = extensions.iterator();
while (iterator.hasNext()) {
JsonNode candidate = iterator.next();
+ String path = "$.custom_extensions[" + index++ + "]";
if (!(candidate instanceof ObjectNode)) {
continue;
}
ObjectNode extension = (ObjectNode) candidate;
- if
(!GRAVITINO_VENDOR.equals(extension.path("vendor_name").asText(null))) {
+ if
(!GRAVITINO_PROPERTIES_VENDOR.equals(extension.path("vendor_name").asText(null)))
{
continue;
}
- ObjectNode payload =
parseExtensionData(extension.path("data").asText(null));
- JsonNode markerNode = payload == null ? null :
payload.get(INTERCHANGE_MARKER);
- if (markerNode == null) {
- continue;
- }
if (found) {
- throw invalid("$.custom_extensions", "contains multiple Gravitino
interchange markers");
- }
- if (!(markerNode instanceof ObjectNode)
- || markerNode.path("version").asInt(-1) != INTERCHANGE_MARKER_VERSION
- || !(markerNode.get("properties") instanceof ObjectNode)) {
throw invalid(
- "$.custom_extensions", "contains an unsupported Gravitino
interchange marker");
+ "$.custom_extensions",
+ "contains multiple " + GRAVITINO_PROPERTIES_VENDOR + "
extensions");
}
- ObjectNode propertyNode = (ObjectNode) markerNode.get("properties");
+ validateObject(extension, path, CUSTOM_EXTENSION_PROPERTIES);
+ ObjectNode propertyNode = parsePropertiesData(extension.get("data"),
path + ".data");
propertyNode
.fields()
.forEachRemaining(
entry -> {
if (!entry.getValue().isTextual()) {
throw invalid(
- "$.custom_extensions",
+ path + ".data",
"Gravitino property '" + entry.getKey() + "' must be a
string");
}
properties.put(entry.getKey(), entry.getValue().textValue());
});
found = true;
- payload.remove(INTERCHANGE_MARKER);
- if (payload.isEmpty()) {
- iterator.remove();
- } else {
- extension.put("data", writeExtensionData(payload));
- }
+ iterator.remove();
}
if (extensions.isEmpty()) {
root.remove("custom_extensions");
@@ -693,31 +639,33 @@ public final class OssieSemanticModelDocumentConverter {
return properties;
}
- private static void ensureNoReservedMarker(ArrayNode extensions) {
- for (JsonNode candidate : extensions) {
- if (!(candidate instanceof ObjectNode)
- ||
!GRAVITINO_VENDOR.equals(candidate.path("vendor_name").asText(null))) {
- continue;
- }
- ObjectNode payload =
parseExtensionData(candidate.path("data").asText(null));
- if (payload != null && payload.has(INTERCHANGE_MARKER)) {
+ private static void ensureNoPropertiesExtension(ArrayNode extensions) {
+ for (int index = 0; index < extensions.size(); index++) {
+ if (GRAVITINO_PROPERTIES_VENDOR.equals(
+ extensions.get(index).path("vendor_name").asText(null))) {
throw invalid(
- "$.custom_extensions", "already contains a reserved Gravitino
interchange marker");
+ "$.custom_extensions[" + index + "].vendor_name",
+ "'" + GRAVITINO_PROPERTIES_VENDOR + "' is reserved for Gravitino
properties");
}
}
}
- @Nullable
- private static ObjectNode parseExtensionData(@Nullable String data) {
- if (data == null) {
- return null;
+ private static ObjectNode parsePropertiesData(@Nullable JsonNode data,
String path) {
+ if (data == null || !data.isTextual()) {
+ throw invalid(path, "must be a JSON string containing an object with
string values");
}
try {
JsonNode parsed =
-
JSON_MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(data);
- return parsed instanceof ObjectNode ? (ObjectNode) parsed : null;
+ JSON_MAPPER
+ .reader()
+ .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .readTree(data.textValue());
+ if (!(parsed instanceof ObjectNode)) {
+ throw invalid(path, "must contain a JSON object with string values");
+ }
+ return (ObjectNode) parsed;
} catch (JsonProcessingException e) {
- return null;
+ throw invalid(path, "cannot parse Gravitino properties: " +
e.getOriginalMessage());
}
}
@@ -726,7 +674,7 @@ public final class OssieSemanticModelDocumentConverter {
return JSON_MAPPER.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new IllegalSemanticModelException(
- e, "Cannot serialize Gravitino interchange extension: %s",
e.getOriginalMessage());
+ e, "Cannot serialize Gravitino properties extension: %s",
e.getOriginalMessage());
}
}
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestSemanticModelOperationDispatcher.java
b/core/src/test/java/org/apache/gravitino/catalog/TestSemanticModelOperationDispatcher.java
index 06d5d051d8..8421bc6472 100644
---
a/core/src/test/java/org/apache/gravitino/catalog/TestSemanticModelOperationDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestSemanticModelOperationDispatcher.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.catalog;
import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
+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 static org.junit.jupiter.api.Assertions.assertArrayEquals;
@@ -49,6 +50,7 @@ import
org.apache.gravitino.exceptions.NoSuchSemanticModelException;
import org.apache.gravitino.exceptions.SemanticModelAlreadyExistsException;
import org.apache.gravitino.lock.LockManager;
import org.apache.gravitino.secret.SecretManager;
+import org.apache.gravitino.semantic.CustomExtension;
import org.apache.gravitino.semantic.Dataset;
import org.apache.gravitino.semantic.OssieDocument;
import org.apache.gravitino.semantic.OssieFormat;
@@ -142,6 +144,42 @@ public class TestSemanticModelOperationDispatcher {
verify(catalogManager, never()).loadCatalog(METADATA_CATALOG_IDENT);
}
+ @Test
+ public void testCreateRejectsReservedPropertiesExtensionWithoutPersisting() {
+ IllegalSemanticModelException exception =
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () ->
+ dispatcher.createSemanticModel(
+ MODEL_IDENT, "Sales", definitionWithReservedExtension(),
Map.of()));
+
+ assertTrue(exception.getMessage().contains("reserved for Gravitino
properties"));
+ assertFalse(dispatcher.semanticModelExists(MODEL_IDENT));
+ }
+
+ @Test
+ public void
testReplaceRejectsReservedPropertiesExtensionWithoutPersistingOtherChanges() {
+ SemanticModel original =
+ dispatcher.createSemanticModel(
+ MODEL_IDENT, "Original", validDefinition(), Map.of("owner",
"sales"));
+
+ IllegalSemanticModelException exception =
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () ->
+ dispatcher.alterSemanticModel(
+ MODEL_IDENT,
+ SemanticModelChange.updateComment("Must not persist"),
+ SemanticModelChange.setProperty("owner", "changed"),
+
SemanticModelChange.replaceDefinition(definitionWithReservedExtension())));
+
+ assertTrue(exception.getMessage().contains("reserved for Gravitino
properties"));
+ SemanticModel loaded = dispatcher.loadSemanticModel(MODEL_IDENT);
+ assertEquals(original.comment(), loaded.comment());
+ assertEquals(original.properties(), loaded.properties());
+ assertEquals(original.definition(), loaded.definition());
+ }
+
@Test
public void testAlterRejectsBlankOssieVersionBeforeCatalogLookup() {
assertThrows(
@@ -286,11 +324,23 @@ public class TestSemanticModelOperationDispatcher {
@ParameterizedTest
@EnumSource(OssieFormat.class)
public void testOssieExportImportRoundTrip(OssieFormat format) {
+ CustomExtension ordinary =
+ CustomExtension.builder()
+ .withVendorName("GRAVITINO")
+ .withData("{\"_apache_gravitino_interchange\":1}")
+ .build();
+ SemanticModelDefinition baseDefinition = validDefinition();
+ SemanticModelDefinition definition =
+ SemanticModelDefinition.builder()
+ .withDatasets(baseDefinition.datasets())
+ .withRelationships(baseDefinition.relationships())
+ .withCustomExtensions(new CustomExtension[] {ordinary})
+ .build();
SemanticModel original =
dispatcher.createSemanticModel(
MODEL_IDENT,
"Sales",
- validDefinition(),
+ definition,
Map.of("domain", "sales", PROPERTY_OSSIE_VERSION,
"future-version"));
OssieDocument document = dispatcher.exportOssieSemanticModel(MODEL_IDENT,
format);
@@ -385,6 +435,18 @@ public class TestSemanticModelOperationDispatcher {
.build();
}
+ private static SemanticModelDefinition definitionWithReservedExtension() {
+ CustomExtension extension =
+ CustomExtension.builder()
+ .withVendorName(GRAVITINO_PROPERTIES_VENDOR)
+ .withData("not JSON")
+ .build();
+ return SemanticModelDefinition.builder()
+ .withDatasets(validDefinition().datasets())
+ .withCustomExtensions(new CustomExtension[] {extension})
+ .build();
+ }
+
private static Dataset dataset(
String name, String source, String[] primaryKey, String[][] uniqueKeys) {
return Dataset.builder()
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestSemanticModelValidator.java
b/core/src/test/java/org/apache/gravitino/catalog/TestSemanticModelValidator.java
index cff181337a..757354f7d7 100644
---
a/core/src/test/java/org/apache/gravitino/catalog/TestSemanticModelValidator.java
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestSemanticModelValidator.java
@@ -18,6 +18,7 @@
*/
package org.apache.gravitino.catalog;
+import static
org.apache.gravitino.semantic.CustomExtension.GRAVITINO_PROPERTIES_VENDOR;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -105,6 +106,50 @@ public class TestSemanticModelValidator {
"datasets[0].source: must contain exactly catalog.schema.name, but was
'sales.orders'");
}
+ @Test
+ public void testRejectsReservedRootExtensionRegardlessOfData() {
+ for (String data : new String[] {"{}", "{\"owner\":\"analytics\"}", "not
JSON"}) {
+ CustomExtension reserved =
+ CustomExtension.builder()
+ .withVendorName(GRAVITINO_PROPERTIES_VENDOR)
+ .withData(data)
+ .build();
+ SemanticModelDefinition definition =
+ SemanticModelDefinition.builder()
+ .withDatasets(new Dataset[] {dataset("orders")})
+ .withCustomExtensions(new CustomExtension[] {reserved})
+ .build();
+
+ assertInvalid(
+ definition,
+ "customExtensions[0].vendorName: 'GRAVITINO_PROPERTIES' is reserved
for Gravitino properties");
+ }
+ }
+
+ @Test
+ public void testPreservesOrdinaryRootAndNestedPropertiesVendorExtensions() {
+ CustomExtension nested =
+ CustomExtension.builder()
+ .withVendorName(GRAVITINO_PROPERTIES_VENDOR)
+ .withData("not JSON")
+ .build();
+ Dataset orders =
+ Dataset.builder()
+ .withName("orders")
+ .withSource(NameIdentifier.of("sales", "mart", "orders"))
+ .withCustomExtensions(new CustomExtension[] {nested})
+ .build();
+ CustomExtension ordinary =
+ CustomExtension.builder().withVendorName("GRAVITINO").withData("not
JSON").build();
+ SemanticModelDefinition definition =
+ SemanticModelDefinition.builder()
+ .withDatasets(new Dataset[] {orders})
+ .withCustomExtensions(new CustomExtension[] {ordinary})
+ .build();
+
+ assertDoesNotThrow(() ->
SemanticModelValidator.validateDefinition(definition));
+ }
+
@Test
public void testFieldNamesAreUniquePerDataset() {
Dataset duplicateFields =
diff --git
a/core/src/test/java/org/apache/gravitino/semantic/TestOssieSemanticModelDocumentConverter.java
b/core/src/test/java/org/apache/gravitino/semantic/TestOssieSemanticModelDocumentConverter.java
index 43eaa6634d..3bb74e6c1c 100644
---
a/core/src/test/java/org/apache/gravitino/semantic/TestOssieSemanticModelDocumentConverter.java
+++
b/core/src/test/java/org/apache/gravitino/semantic/TestOssieSemanticModelDocumentConverter.java
@@ -18,10 +18,12 @@
*/
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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -37,6 +39,8 @@ import
org.apache.gravitino.exceptions.IllegalSemanticModelException;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.SemanticModelEntity;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
/** Tests standalone Apache Ossie document import, export, and conversion
diagnostics. */
public class TestOssieSemanticModelDocumentConverter {
@@ -76,8 +80,8 @@ public class TestOssieSemanticModelDocumentConverter {
custom_extensions:
- vendor_name: EXAMPLE
data: '{"certified":true}'
- - vendor_name: GRAVITINO
- data:
'{"_apache_gravitino_interchange":{"version":1,"properties":{"domain":"sales"}}}'
+ - vendor_name: GRAVITINO_PROPERTIES
+ data: '{"domain":"sales"}'
""";
SemanticModelCreateRequest yamlRequest =
@@ -143,11 +147,11 @@ public class TestOssieSemanticModelDocumentConverter {
assertFalse(json.has("definition"));
assertFalse(json.has("properties"));
assertFalse(json.has("audit"));
- assertEquals("GRAVITINO",
json.at("/custom_extensions/1/vendor_name").textValue());
- JsonNode marker =
JSON_MAPPER.readTree(json.at("/custom_extensions/1/data").textValue());
assertEquals(
- "sales",
marker.at("/_apache_gravitino_interchange/properties/domain").textValue());
-
assertFalse(marker.at("/_apache_gravitino_interchange/properties").has(PROPERTY_OSSIE_VERSION));
+ GRAVITINO_PROPERTIES_VENDOR,
json.at("/custom_extensions/1/vendor_name").textValue());
+ JsonNode properties =
JSON_MAPPER.readTree(json.at("/custom_extensions/1/data").textValue());
+ assertEquals(JSON_MAPPER.readTree("{\"domain\":\"sales\"}"), properties);
+ assertFalse(properties.has(PROPERTY_OSSIE_VERSION));
SemanticModelCreateRequest jsonRoundTrip =
OssieSemanticModelDocumentConverter.importDocument(jsonDocument);
@@ -251,9 +255,6 @@ public class TestOssieSemanticModelDocumentConverter {
"version: 0.2.0.dev0\nname: first\ndatasets: []\n"
+ "---\nversion: 0.2.0.dev0\nname: second\ndatasets: []\n",
"Trailing token");
- assertInvalid(
- "x".repeat(OssieSemanticModelDocumentConverter.MAX_DOCUMENT_LENGTH +
1),
- "exceeds the maximum length");
}
@Test
@@ -309,19 +310,6 @@ public class TestOssieSemanticModelDocumentConverter {
OssieDocument.json("{\"name\":\"first\",\"name\":\"second\"}"),
"Duplicate field 'name'");
assertInvalid(OssieDocument.json("{} {}"), "Trailing token");
assertInvalid(OssieDocument.json("{\"name\":}"), "Cannot parse Apache
Ossie JSON");
- assertInvalid(
-
OssieDocument.json("x".repeat(OssieSemanticModelDocumentConverter.MAX_DOCUMENT_LENGTH
+ 1)),
- "exceeds the maximum length");
- }
-
- @Test
- public void testLimitsDocumentNesting() {
- String arrays = "[".repeat(110) + "0" + "]".repeat(110);
- assertInvalid(OssieDocument.json("{\"nested\":" + arrays + "}"), "nesting
depth");
- assertInvalid(OssieDocument.yaml("nested: " + arrays), "nesting depth");
- String objects = "{\"nested\":".repeat(110) + "0" + "}".repeat(110);
- assertInvalid(OssieDocument.json(objects), "nesting depth");
- assertInvalid(OssieDocument.yaml(objects), "nesting depth");
}
@Test
@@ -334,45 +322,141 @@ public class TestOssieSemanticModelDocumentConverter {
- name: orders
source: sales.mart.orders
custom_extensions:
- - vendor_name: GRAVITINO
- data:
'{"_apache_gravitino_interchange":{"version":1,"properties":{"ossie-version":"extension-version"}}}'
+ - vendor_name: GRAVITINO_PROPERTIES
+ data: '{"ossie-version":"extension-version"}'
""";
assertInvalid(document, "property 'ossie-version' conflicts with
$.version");
}
- @Test
- public void testLimitsNestedCustomExtensionParsing() throws Exception {
- String nested = "{}";
- for (int depth = 0; depth < 110; depth++) {
- nested = "{\"nested\":" + nested + "}";
+ @ParameterizedTest
+ @EnumSource(OssieFormat.class)
+ public void testRejectsInvalidPropertiesExtensionData(OssieFormat format)
throws Exception {
+ String[] payloads = {
+ "",
+ "{",
+ "{} {}",
+ "{\"domain\":\"sales\",\"domain\":\"other\"}",
+ "null",
+ "[]",
+ "123",
+ "\"sales\""
+ };
+ for (String payload : payloads) {
+ assertInvalid(
+ ossieDocument(documentWithExtension(GRAVITINO_PROPERTIES_VENDOR,
payload), format),
+ "$.custom_extensions[0].data");
+ }
+ for (String value : new String[] {"null", "true", "1", "[]", "{}"}) {
+ assertInvalid(
+ ossieDocument(
+ documentWithExtension(GRAVITINO_PROPERTIES_VENDOR,
"{\"domain\":" + value + "}"),
+ format),
+ "Gravitino property 'domain' must be a string");
}
- String payload =
-
"{\"_apache_gravitino_interchange\":{\"version\":1,\"properties\":{\"domain\":\"sales\"}},\"nested\":"
- + nested
- + "}";
-
- SemanticModelCreateRequest request = importWithGravitinoExtension(payload);
- assertFalse(request.getProperties().containsKey("domain"));
- assertEquals(payload, request.toDefinition().customExtensions()[0].data());
}
@Test
- public void
testPreservesMalformedCustomExtensionJsonWithoutExtractingProperties()
- throws Exception {
- String marker =
-
"{\"_apache_gravitino_interchange\":{\"version\":1,\"properties\":{\"domain\":\"sales\"}}}";
+ public void testValidatesPropertiesExtensionBeforeConsumingIt() throws
Exception {
+ ObjectNode document = documentWithExtension(GRAVITINO_PROPERTIES_VENDOR,
"{}");
+ ObjectNode extension = (ObjectNode)
document.path("custom_extensions").get(0);
+ extension.put("extra", "not allowed");
+ assertInvalid(ossieDocument(document, OssieFormat.JSON),
"$.custom_extensions[0].extra");
+ extension.remove("extra");
+ extension.put("data", 123);
+ assertInvalid(ossieDocument(document, OssieFormat.JSON),
"$.custom_extensions[0].data");
+ extension.remove("data");
+ assertInvalid(ossieDocument(document, OssieFormat.JSON),
"$.custom_extensions[0].data");
+ }
+
+ @ParameterizedTest
+ @EnumSource(OssieFormat.class)
+ public void testRejectsDuplicatePropertiesExtensions(OssieFormat format)
throws Exception {
+ ObjectNode document = documentWithExtension(GRAVITINO_PROPERTIES_VENDOR,
"{}");
+ document
+ .withArray("custom_extensions")
+ .add(document.path("custom_extensions").get(0).deepCopy());
+ assertInvalid(
+ ossieDocument(document, format), "contains multiple
GRAVITINO_PROPERTIES extensions");
+ }
+
+ @ParameterizedTest
+ @EnumSource(OssieFormat.class)
+ public void testConsumesEmptyPropertiesExtension(OssieFormat format) throws
Exception {
+ SemanticModelCreateRequest request =
+ OssieSemanticModelDocumentConverter.importDocument(
+ ossieDocument(documentWithExtension(GRAVITINO_PROPERTIES_VENDOR,
"{}"), format));
+ assertEquals(Map.of(PROPERTY_OSSIE_VERSION, DEFAULT_OSSIE_VERSION),
request.getProperties());
+ assertNull(request.toDefinition().customExtensions());
+ }
+
+ @ParameterizedTest
+ @EnumSource(OssieFormat.class)
+ public void testPreservesOrdinaryGravitinoExtensions(OssieFormat format)
throws Exception {
String[] payloads = {
- marker + " {}",
- marker.substring(0, marker.length() - 1) +
",\"_apache_gravitino_interchange\":{}}"
+ "{\"_apache_gravitino_interchange\":1}",
+
"{\"_apache_gravitino_interchange\":{\"version\":1,\"properties\":{\"domain\":\"sales\"}}}",
+ "{\"domain\":\"sales\"}",
+ "{\"domain\":\"sales\",\"domain\":\"other\"}",
+ "{} {}",
+ "not JSON"
};
for (String payload : payloads) {
- SemanticModelCreateRequest request =
importWithGravitinoExtension(payload);
- assertFalse(request.getProperties().containsKey("domain"));
+ SemanticModelCreateRequest request =
+ OssieSemanticModelDocumentConverter.importDocument(
+ ossieDocument(documentWithExtension("GRAVITINO", payload),
format));
+ assertEquals(Map.of(PROPERTY_OSSIE_VERSION, DEFAULT_OSSIE_VERSION),
request.getProperties());
assertEquals(payload,
request.toDefinition().customExtensions()[0].data());
+
+ SemanticModel model =
+ semanticModel(
+ request.toDefinition(),
+ Map.of("owner", "analytics", PROPERTY_OSSIE_VERSION,
DEFAULT_OSSIE_VERSION));
+ SemanticModelCreateRequest roundTrip =
+ OssieSemanticModelDocumentConverter.importDocument(
+ OssieSemanticModelDocumentConverter.exportDocument(model,
format));
+ assertEquals(model.definition(), roundTrip.toDefinition());
+ assertEquals(model.properties(), roundTrip.getProperties());
}
}
+ @ParameterizedTest
+ @EnumSource(OssieFormat.class)
+ public void testPreservesNestedPropertiesVendorExtension(OssieFormat format)
throws Exception {
+ CustomExtension nested =
+ CustomExtension.builder()
+ .withVendorName(GRAVITINO_PROPERTIES_VENDOR)
+ .withData("{\"nested\":true}")
+ .build();
+ Dataset dataset =
+ Dataset.builder()
+ .withName("orders")
+ .withSource(NameIdentifier.of("sales", "mart", "orders"))
+ .withCustomExtensions(new CustomExtension[] {nested})
+ .build();
+ SemanticModel model =
+ semanticModel(
+ SemanticModelDefinition.builder().withDatasets(new Dataset[]
{dataset}).build(),
+ Map.of("owner", "analytics", PROPERTY_OSSIE_VERSION,
DEFAULT_OSSIE_VERSION));
+
+ SemanticModelCreateRequest roundTrip =
+ OssieSemanticModelDocumentConverter.importDocument(
+ OssieSemanticModelDocumentConverter.exportDocument(model, format));
+ assertEquals(model.definition(), roundTrip.toDefinition());
+ assertEquals(model.properties(), roundTrip.getProperties());
+ }
+
+ @Test
+ public void testDoesNotExportPropertiesExtensionForVersionOnly() throws
Exception {
+ SemanticModel model =
+ semanticModel(definition(), Map.of(PROPERTY_OSSIE_VERSION,
DEFAULT_OSSIE_VERSION));
+ JsonNode document =
+ JSON_MAPPER.readTree(
+ OssieSemanticModelDocumentConverter.exportDocument(model,
OssieFormat.JSON).content());
+ assertEquals(1, document.path("custom_extensions").size());
+ assertEquals("EXAMPLE",
document.at("/custom_extensions/0/vendor_name").textValue());
+ }
+
@Test
public void testRejectsBlankOssieVersionPropertyOnExport() {
IllegalSemanticModelException exception =
@@ -390,8 +474,8 @@ public class TestOssieSemanticModelDocumentConverter {
public void testRejectsReservedExtensionCollisionOnExport() {
CustomExtension reserved =
CustomExtension.builder()
- .withVendorName("GRAVITINO")
-
.withData("{\"_apache_gravitino_interchange\":{\"version\":1,\"properties\":{}}}")
+ .withVendorName(GRAVITINO_PROPERTIES_VENDOR)
+ .withData("{}")
.build();
SemanticModelDefinition definition =
SemanticModelDefinition.builder()
@@ -405,7 +489,7 @@ public class TestOssieSemanticModelDocumentConverter {
() ->
OssieSemanticModelDocumentConverter.exportDocument(
semanticModel(definition, Map.of()), OssieFormat.JSON));
- assertTrue(exception.getMessage().contains("reserved Gravitino interchange
marker"));
+ assertTrue(exception.getMessage().contains("reserved for Gravitino
properties"));
}
@Test
@@ -441,8 +525,7 @@ public class TestOssieSemanticModelDocumentConverter {
"TRINO",
roundTrip.toDefinition().metrics()[0].expression().dialects()[0].dialect());
}
- private static SemanticModelCreateRequest
importWithGravitinoExtension(String payload)
- throws Exception {
+ private static ObjectNode documentWithExtension(String vendorName, String
payload) {
ObjectNode root = JSON_MAPPER.createObjectNode();
root.put("version", DEFAULT_OSSIE_VERSION);
root.put("name", "sales");
@@ -450,10 +533,14 @@ public class TestOssieSemanticModelDocumentConverter {
dataset.put("name", "orders");
dataset.put("source", "sales.mart.orders");
ObjectNode extension = root.putArray("custom_extensions").addObject();
- extension.put("vendor_name", "GRAVITINO");
+ extension.put("vendor_name", vendorName);
extension.put("data", payload);
- return OssieSemanticModelDocumentConverter.importDocument(
- OssieDocument.json(JSON_MAPPER.writeValueAsString(root)));
+ return root;
+ }
+
+ private static OssieDocument ossieDocument(ObjectNode root, OssieFormat
format) throws Exception {
+ String content = JSON_MAPPER.writeValueAsString(root);
+ return format == OssieFormat.JSON ? OssieDocument.json(content) :
OssieDocument.yaml(content);
}
private static SemanticModel semanticModel(
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
index 3dd3d5050d..88d250556b 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
@@ -167,6 +167,10 @@ public class SemanticModelOperations {
/**
* Imports a standalone Apache Ossie YAML or JSON document as a Semantic
Model.
*
+ * <p>The request body is raw document text, not a JSON-encoded string. The
{@code Content-Type}
+ * header selects the document format; an absent header defaults to YAML.
Parsing does not fall
+ * back to another format if the document is invalid for the selected parser.
+ *
* @param metalake The metalake name.
* @param catalog The catalog name.
* @param schema The schema name.
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
index 85cacbd7db..4d6e6dd177 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
@@ -578,8 +578,8 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
expression: orders.order_id
datatype: String
custom_extensions:
- - vendor_name: GRAVITINO
- data:
'{"_apache_gravitino_interchange":{"version":1,"properties":{"domain":"sales"}}}'
+ - vendor_name: GRAVITINO_PROPERTIES
+ data: '{"domain":"sales"}'
""";
Response yamlResponse = postDocument(semanticModelPath() + "/ossie", yaml,
"application/yaml");