Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@
import org.apache.hugegraph.define.Checkable;
import org.apache.hugegraph.exception.NotFoundException;
import org.apache.hugegraph.metrics.MetricsUtil;
import org.apache.hugegraph.schema.PropertyKey;
import org.apache.hugegraph.space.GraphSpace;
import org.apache.hugegraph.space.SchemaTemplate;
import org.apache.hugegraph.space.Service;
import org.apache.hugegraph.traversal.optimize.TraversalUtil;
import org.apache.hugegraph.util.E;
import org.apache.hugegraph.util.InsertionOrderUtil;
import org.apache.hugegraph.util.JsonUtil;
import org.apache.hugegraph.util.Log;
import org.apache.tinkerpop.gremlin.process.traversal.P;
import org.slf4j.Logger;

import com.codahale.metrics.Meter;
Expand Down Expand Up @@ -222,14 +225,41 @@ protected static void checkUpdatingBody(Collection<? extends Checkable> bodies)
}

@SuppressWarnings("unchecked")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The new method was inserted between @SuppressWarnings("unchecked") and parseProperties(), so the annotation now applies to normalizeProperties() and parseProperties() loses it. The annotation also sits above the Javadoc, so the Javadoc tool no longer attaches that comment to normalizeProperties(). PropertyKey.java has the same problem at lines 123-133: the block that describes normalizeDefaultValue() sits directly above the separate Javadoc of undoExact(), so neither method gets the right documentation.

Requested change: put @SuppressWarnings("unchecked") back directly above parseProperties(), and move the normalizeDefaultValue() Javadoc in PropertyKey to that method.

/**
* Convert each filter value to the runtime type of its property key the
* way the traversal does (TraversalUtil.validPropertyValue, which knows
* the key's cardinality: a list on a single key converts every member,
* a scalar on a LIST/SET key stays a scalar for membership). A JSON
* fraction arrives as BigDecimal, so a DECIMAL value keeps every digit
* and a DOUBLE value becomes a double as before. Predicates (P.gt(...))
* are converted when the traversal builds its conditions; values of
* unknown keys are left as they are.
*/
protected static void normalizeProperties(HugeGraph g, Map<String, Object> props) {
for (Map.Entry<String, Object> entry : props.entrySet()) {
Object value = entry.getValue();
if (value == null || value instanceof P) {
continue;
}
PropertyKey pkey;
try {
pkey = g.propertyKey(entry.getKey());
} catch (NotFoundException e) {
continue;
}
entry.setValue(TraversalUtil.validPropertyValue(value, pkey));
}
}

protected static Map<String, Object> parseProperties(String properties) {
if (properties == null || properties.isEmpty()) {
return ImmutableMap.of();
}

Map<String, Object> props = null;
try {
props = JsonUtil.fromJson(properties, Map.class);
// Exact fractions: a DECIMAL filter keeps every digit
props = JsonUtil.fromJsonExact(properties, Map.class);
} catch (Exception ignored) {
// ignore
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.apache.hugegraph.config.ServerOptions;
import org.apache.hugegraph.define.Checkable;
import org.apache.hugegraph.define.UpdateStrategy;
import org.apache.hugegraph.schema.PropertyKey;
import org.apache.hugegraph.metrics.MetricsUtil;
import org.apache.hugegraph.server.RestServer;
import org.apache.hugegraph.structure.HugeElement;
Expand All @@ -39,6 +40,7 @@
import com.codahale.metrics.Meter;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

import io.swagger.v3.oas.annotations.media.Schema;

Expand Down Expand Up @@ -92,6 +94,7 @@ protected abstract static class JsonElement implements Checkable {
public String label;
@Schema(description = "The properties of the vertex/edge in key-value format")
@JsonProperty("properties")
@JsonDeserialize(using = PropertiesDeserializer.class)
public Map<String, Object> properties;
@Schema(description = "The type of element (vertex or edge)", hidden = true)
@JsonProperty("type")
Expand All @@ -108,6 +111,18 @@ protected abstract static class JsonElement implements Checkable {

protected void updateExistElement(JsonElement oldElement, JsonElement newElement,
Map<String, UpdateStrategy> strategies) {
this.updateExistElement(null, oldElement, newElement, strategies);
}

/**
* Combine two JSON elements of the same id within one batch request. With
* a graph the raw JSON values are first normalised to the property key's
* data type (a decimal or a date arrives as a string), so the strategy
* sees typed values on both sides.
*/
protected void updateExistElement(HugeGraph g, JsonElement oldElement,
JsonElement newElement,
Map<String, UpdateStrategy> strategies) {
if (oldElement == null) {
return;
}
Expand All @@ -118,9 +133,15 @@ protected void updateExistElement(JsonElement oldElement, JsonElement newElement
UpdateStrategy updateStrategy = kv.getValue();
if (oldElement.properties.get(key) != null &&
newElement.properties.get(key) != null) {
Object value = updateStrategy.checkAndUpdateProperty(
oldElement.properties.get(key),
newElement.properties.get(key));
Object oldValue = oldElement.properties.get(key);
Object newValue = newElement.properties.get(key);
if (g != null) {
PropertyKey propertyKey = g.propertyKey(key);
oldValue = propertyKey.validValueOrThrow(oldValue);
newValue = propertyKey.validValueOrThrow(newValue);
}
Object value = updateStrategy.checkAndUpdateProperty(oldValue,
newValue);
newElement.properties.put(key, value);
} else if (oldElement.properties.get(key) != null &&
newElement.properties.get(key) == null) {
Expand All @@ -142,10 +163,13 @@ protected void updateExistElement(HugeGraph g, Element oldElement, JsonElement n
UpdateStrategy updateStrategy = kv.getValue();
if (oldElement.property(key).isPresent() &&
newElement.properties.get(key) != null) {
PropertyKey propertyKey = g.propertyKey(key);
// The stored value is typed; normalise the JSON one to match
Object newValue = propertyKey.validValueOrThrow(
newElement.properties.get(key));
Object value = updateStrategy.checkAndUpdateProperty(
oldElement.property(key).value(),
newElement.properties.get(key));
value = g.propertyKey(key).validValueOrThrow(value);
oldElement.property(key).value(), newValue);
value = propertyKey.validValueOrThrow(value);
newElement.properties.put(key, value);
} else if (oldElement.property(key).isPresent() &&
newElement.properties.get(key) == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ public String update(@Context HugeConfig config,
Id newEdgeId = getEdgeId(graph(manager, graphSpace, graph),
newEdge);
JsonEdge oldEdge = map.get(newEdgeId);
this.updateExistElement(oldEdge, newEdge, req.updateStrategies);
this.updateExistElement(g, oldEdge, newEdge, req.updateStrategies);
map.put(newEdgeId, newEdge);
});

Expand Down Expand Up @@ -341,6 +341,7 @@ public String list(@Context GraphManager manager,
}
}

normalizeProperties(g, props);
for (Map.Entry<String, Object> entry : props.entrySet()) {
traversal = traversal.has(entry.getKey(), entry.getValue());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* 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.hugegraph.api.graph;

import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;

/**
* Reads the "properties" object of a vertex or edge body so that a JSON
* fraction keeps every digit: it becomes a BigDecimal instead of a double,
* which is what a DECIMAL property key needs and what every numeric key
* narrows through DataType.valueToNumber as before. Only property values

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The exact-number parser is applied only to graph element properties; schema userdata still uses Jackson’s default untyped numbers. A DECIMAL ~default_value such as 0.1234567890123456789 therefore reaches PropertyKeyAPI as a Double, and valueToDecimal() can only reconstruct its already-rounded text. Please preserve fractional defaults as BigDecimal or reject numeric fractions and require strings, with an exact default-value case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, thanks. Done in the first of your forms: ~default_value stays a number and arrives exactly. user_data in PropertyKeyAPI.JsonPropertyKey carries @JsonDeserialize(using = UserdataDeserializer.class): the same token walk as PropertiesDeserializer, a fraction as BigDecimal, everything else as before, wrapped in Userdata (with the ~create_time normalization). The same gap existed on the way back from the backend: readUserdata in BinarySerializer and TextSerializer parsed the JSON with the default mapper, so after a restart an exact default would have come back as a double; both now use JsonUtil.fromJsonExact, and PropertyKey normalizes ~default_value to the key's runtime type on every userdata write path (userdata(key, value) and userdata(Userdata), through validValue): a DOUBLE key keeps and returns 1.5 as a number, a DECIMAL key keeps the exact BigDecimal and returns it the way property values are returned. The first E2E run caught that without this normalization a DOUBLE default would have come back from GET as a string. Tests: PropertiesDeserializerTest.testPropertyKeyUserdataIsExact (0.1234567890123456789 as BigDecimal, a string and an int in userdata untouched, no userdata = null), JsonUtilTest for fromJsonExact, VertexApiTest: a DECIMAL key fee with "~default_value": 0.1234567890123456789, GET of the key returns every digit, a vertex created without fee gets the exact default.

* are read this way; the rest of the request body (job parameters, schema
* userdata, query options) keeps Jackson's default number types.
*/
public class PropertiesDeserializer extends JsonDeserializer<Map<String, Object>> {

@Override
public Map<String, Object> deserialize(JsonParser parser,
DeserializationContext ctxt)
throws IOException {
JsonToken token = parser.currentToken();
if (token == JsonToken.VALUE_NULL) {
return null;
}
if (token != JsonToken.START_OBJECT) {
throw JsonMappingException.from(parser,
"Expected an object for 'properties', but got " + token);
}
return readObject(parser);
}

private static Map<String, Object> readObject(JsonParser parser)
throws IOException {
Map<String, Object> object = new LinkedHashMap<>();
while (parser.nextToken() != JsonToken.END_OBJECT) {
String name = parser.currentName();
parser.nextToken();
object.put(name, readValue(parser));
}
return object;
}

private static List<Object> readArray(JsonParser parser)
throws IOException {
List<Object> array = new ArrayList<>();
while (parser.nextToken() != JsonToken.END_ARRAY) {
array.add(readValue(parser));
}
return array;
}

/** One value at the parser's current token: exact fractions, nested objects and arrays. */
public static Object readValue(JsonParser parser) throws IOException {
JsonToken token = parser.currentToken();
switch (token) {
case START_OBJECT:
return readObject(parser);
case START_ARRAY:
return readArray(parser);
case VALUE_STRING:
return parser.getText();
case VALUE_NUMBER_INT:
return parser.getNumberValue();
case VALUE_NUMBER_FLOAT:
// Exact: the literal's digits, not the nearest double
return parser.getDecimalValue();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Vertex and edge list filters still parse query properties through API.parseProperties(..., Map.class), where untyped fractional numbers become Double; 0.100000000000000001 is rounded to 0.1 before DECIMAL comparison. This parser only handles request-body properties. Please preserve filter precision or normalize each value against its schema type before building the traversal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. API.parseProperties reads the parameter through JsonUtil.fromJsonExact (a fraction as BigDecimal), and VertexAPI.list / EdgeAPI.list normalize each plain value to its property key's type before building the traversal (API.normalizeProperties: PropertyKey.validValue; P.* predicates, collections and unknown keys are left as they are). DECIMAL keeps every digit, DOUBLE gets a double as before, INT an integral literal as before. VertexApiTest: the same edge filter as in round 3, but with a number literal instead of a string: the 39-digit amount hits, the same value with the last digit changed misses (on hstore through the pushdown). For what it is worth, the same test run by mistake against a build of the round-3 head reproduced exactly the miss you describe.

case VALUE_TRUE:
return Boolean.TRUE;
case VALUE_FALSE:
return Boolean.FALSE;
case VALUE_NULL:
return null;
default:
throw JsonMappingException.from(parser,
"Unexpected token in 'properties': " + token);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public String update(@Context HugeConfig config,
req.jsonVertices.forEach(newVertex -> {
Id newVertexId = getVertexId(g, newVertex);
JsonVertex oldVertex = map.get(newVertexId);
this.updateExistElement(oldVertex, newVertex, req.updateStrategies);
this.updateExistElement(g, oldVertex, newVertex, req.updateStrategies);
map.put(newVertexId, newVertex);
});

Expand Down Expand Up @@ -286,6 +286,7 @@ public String list(@Context GraphManager manager,
}
}

normalizeProperties(g, props);
for (Map.Entry<String, Object> entry : props.entrySet()) {
traversal = traversal.has(entry.getKey(), entry.getValue());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import com.codahale.metrics.annotation.Timed;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.google.common.collect.ImmutableMap;

import io.swagger.v3.oas.annotations.Parameter;
Expand Down Expand Up @@ -254,6 +255,7 @@ private static class JsonPropertyKey implements Checkable {
public String[] properties;
@Schema(description = "User-defined metadata")
@JsonProperty("user_data")
@JsonDeserialize(using = UserdataDeserializer.class)
public Userdata userdata;
@Schema(description = "Whether to check if property key exists before creation")
@JsonProperty("check_exist")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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.hugegraph.api.schema;

import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;

import org.apache.hugegraph.api.graph.PropertiesDeserializer;
import org.apache.hugegraph.schema.Userdata;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;

/**
* The userdata of a property key: {@code ~default_value} is read like an
* element property (a JSON fraction becomes a BigDecimal with every digit,
* so a DECIMAL default reaches the key exactly), every other entry keeps
* Jackson's default types, so custom metadata such as {@code {"rate":0.85}}
* stays a double and round-trips as a JSON number.
*/
public class UserdataDeserializer extends StdDeserializer<Userdata> {

private static final long serialVersionUID = 1L;

public UserdataDeserializer() {
super(Userdata.class);
}

@Override
public Userdata deserialize(JsonParser parser, DeserializationContext context)
throws IOException {
JsonToken token = parser.currentToken();
if (token == JsonToken.VALUE_NULL) {
return null;
}
if (token != JsonToken.START_OBJECT) {
throw JsonMappingException.from(parser,
"Expected an object for 'user_data', but got " + token);
}
Map<String, Object> map = new LinkedHashMap<>();
while (parser.nextToken() != JsonToken.END_OBJECT) {
String name = parser.currentName();
parser.nextToken();
if (Userdata.DEFAULT_VALUE.equals(name)) {
map.put(name, PropertiesDeserializer.readValue(parser));
} else {
map.put(name, context.readValue(parser, Object.class));
}
}
return new Userdata(map);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,8 @@ private enum ValueType {
FLOAT(DataType.FLOAT),
DOUBLE(DataType.DOUBLE),
DATE(DataType.DATE),
UUID(DataType.UUID);
UUID(DataType.UUID),
DECIMAL(DataType.DECIMAL);

private final DataType dataType;

Expand Down
Loading
Loading