From a28554e9b37282ac72a733675fb4e6ba77d46f49 Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Mon, 14 Sep 2026 14:21:11 +0000 Subject: [PATCH 01/11] feat(core): add DECIMAL property data type (java.math.BigDecimal) A new DataType.DECIMAL(12) stores arbitrary-precision decimals exactly: unscaled two's-complement bytes plus scale in BytesBuffer (server and struct copies), a string on the JSON wire (both a string and a number literal are accepted on input), exact equality in ConditionQuery, and the store-side row decoder maps it to a string variant. It is deliberately not a "number" in the DataType.isNumber() sense: there is no fixed-width sortable encoding, so a decimal property key can't be a sort key, an index field of any type, or an OLAP range property; the schema builders reject those explicitly. SUM/MAX/MIN aggregate types and the batch-update SUM/BIGGER/SMALLER strategies, which already compute in BigDecimal, keep the full precision (e.g. uint256 token balances). Tests: DataTypeTest, BytesBufferTest, JsonUtilTest, PropertyKeyCoreTest, IndexLabelCoreTest, EdgeLabelCoreTest, VertexCoreTest, VertexApiTest (batch update with SUM/BIGGER on 2^256-1), struct PropertyKeyTest. Co-Authored-By: Claude Fable 5.1 --- .../apache/hugegraph/api/graph/BatchAPI.java | 34 ++++-- .../apache/hugegraph/api/graph/EdgeAPI.java | 2 +- .../apache/hugegraph/api/graph/VertexAPI.java | 2 +- .../hugegraph/backend/cache/OffheapCache.java | 3 +- .../backend/query/ConditionQuery.java | 5 + .../backend/serializer/BytesBuffer.java | 14 ++- .../hugegraph/io/HugeGraphSONModule.java | 38 +++++++ .../apache/hugegraph/schema/PropertyKey.java | 6 ++ .../schema/builder/EdgeLabelBuilder.java | 4 + .../schema/builder/IndexLabelBuilder.java | 3 + .../schema/builder/PropertyKeyBuilder.java | 9 +- .../hugegraph/type/define/DataType.java | 51 ++++++++- .../apache/hugegraph/api/VertexApiTest.java | 100 ++++++++++++++++++ .../hugegraph/core/EdgeLabelCoreTest.java | 30 ++++++ .../hugegraph/core/IndexLabelCoreTest.java | 37 +++++++ .../hugegraph/core/PropertyKeyCoreTest.java | 48 +++++++++ .../apache/hugegraph/core/VertexCoreTest.java | 87 +++++++++++++++ .../hugegraph/unit/core/DataTypeTest.java | 75 +++++++++++++ .../unit/serializer/BytesBufferTest.java | 38 +++++++ .../hugegraph/unit/util/JsonUtilTest.java | 26 +++++ .../store/business/GraphStoreIterator.java | 5 + .../hugegraph/serializer/BytesBuffer.java | 12 +++ .../hugegraph/struct/schema/PropertyKey.java | 3 + .../hugegraph/type/define/DataType.java | 12 ++- .../struct/schema/PropertyKeyTest.java | 29 +++++ 25 files changed, 660 insertions(+), 13 deletions(-) diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/BatchAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/BatchAPI.java index 85beb142db..0e58d08325 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/BatchAPI.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/BatchAPI.java @@ -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; @@ -108,6 +109,18 @@ protected abstract static class JsonElement implements Checkable { protected void updateExistElement(JsonElement oldElement, JsonElement newElement, Map 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 strategies) { if (oldElement == null) { return; } @@ -118,9 +131,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) { @@ -142,10 +161,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) { diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/EdgeAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/EdgeAPI.java index 1f229cd6b1..429b7c1879 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/EdgeAPI.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/EdgeAPI.java @@ -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); }); diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/VertexAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/VertexAPI.java index af1433ac46..e8db9b99d7 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/VertexAPI.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/VertexAPI.java @@ -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); }); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/OffheapCache.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/OffheapCache.java index 7ed4efcd66..f4080f7bea 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/OffheapCache.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/OffheapCache.java @@ -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; diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/ConditionQuery.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/ConditionQuery.java index 097e98df19..6393c5f826 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/ConditionQuery.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/ConditionQuery.java @@ -947,6 +947,11 @@ private static boolean numberEquals(Object number1, Object number2) { // Otherwise convert to BigDecimal to make two numbers comparable Number n1 = NumericUtil.convertToNumber(number1); Number n2 = NumericUtil.convertToNumber(number2); + if (n1 instanceof BigDecimal || n2 instanceof BigDecimal) { + // Exact: a decimal must not be squeezed through a double + return new BigDecimal(n1.toString()) + .compareTo(new BigDecimal(n2.toString())) == 0; + } BigDecimal b1 = BigDecimal.valueOf(n1.doubleValue()); BigDecimal b2 = BigDecimal.valueOf(n2.doubleValue()); return b1.compareTo(b2) == 0; diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BytesBuffer.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BytesBuffer.java index faf1508299..2b8ccf3ffc 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BytesBuffer.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BytesBuffer.java @@ -17,6 +17,8 @@ package org.apache.hugegraph.backend.serializer; +import java.math.BigDecimal; +import java.math.BigInteger; import java.io.OutputStream; import java.nio.ByteBuffer; import java.util.Arrays; @@ -660,6 +662,13 @@ public void writeProperty(DataType dataType, Object value) { this.writeLong(uuid.getMostSignificantBits()); this.writeLong(uuid.getLeastSignificantBits()); break; + case DECIMAL: + // unscaled two's-complement bytes + scale: exact for any + // precision, 33 bytes for a 78-digit (uint256) value + BigDecimal decimal = (BigDecimal) value; + this.writeBytes(decimal.unscaledValue().toByteArray()); + this.writeVInt(decimal.scale()); + break; default: // TODO: replace Kryo with Fury (https://github.com/apache/fury) this.writeBytes(KryoUtil.toKryoWithType(value)); @@ -693,6 +702,9 @@ public Object readProperty(DataType dataType) { return Blob.wrap(this.readBigBytes()); case UUID: return new UUID(this.readLong(), this.readLong()); + case DECIMAL: + BigInteger unscaled = new BigInteger(this.readBytes()); + return new BigDecimal(unscaled, this.readVInt()); default: // TODO: replace Kryo with Fury (https://github.com/apache/fury) return KryoUtil.fromKryoWithType(this.readBytes()); @@ -872,7 +884,7 @@ public BinaryId parseOlapId(HugeType type, boolean isOlap) { } // Parse id from bytes int start = this.buffer.position(); - // OLAP {PropertyKey}{VertexId} + // OLAP {PropertyKey}{VertexId} if (isOlap) { // Read olap property id first Id pkId = this.readId(); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java index ddb7c1a981..8db7b25fec 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java @@ -19,6 +19,7 @@ import java.io.File; import java.io.IOException; +import java.math.BigDecimal; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -105,6 +106,7 @@ public class HugeGraphSONModule extends TinkerPopJacksonModule { TYPE_DEFINITIONS.put(Optional.class, "Optional"); TYPE_DEFINITIONS.put(Date.class, "Date"); TYPE_DEFINITIONS.put(UUID.class, "UUID"); + TYPE_DEFINITIONS.put(BigDecimal.class, "BigDecimal"); // HugeGraph id serializer TYPE_DEFINITIONS.put(StringId.class, "StringId"); @@ -183,6 +185,10 @@ public static void registerCommonSerializers(SimpleModule module) { module.addSerializer(Blob.class, new BlobSerializer()); module.addDeserializer(Blob.class, new BlobDeserializer()); + + // Decimals travel as strings: JSON numbers are doubles to most clients + module.addSerializer(BigDecimal.class, new BigDecimalSerializer()); + module.addDeserializer(BigDecimal.class, new BigDecimalDeserializer()); } public static void registerIdSerializers(SimpleModule module) { @@ -956,4 +962,36 @@ public Blob deserialize(JsonParser jsonParser, return Blob.wrap(bytes); } } + + private static class BigDecimalSerializer extends StdSerializer { + + public BigDecimalSerializer() { + super(BigDecimal.class); + } + + @Override + public void serialize(BigDecimal decimal, JsonGenerator jsonGenerator, + SerializerProvider provider) throws IOException { + jsonGenerator.writeString(decimal.toPlainString()); + } + } + + private static class BigDecimalDeserializer extends StdDeserializer { + + public BigDecimalDeserializer() { + super(BigDecimal.class); + } + + @Override + public BigDecimal deserialize(JsonParser jsonParser, + DeserializationContext ctxt) + throws IOException { + JsonToken token = jsonParser.getCurrentToken(); + if (token == JsonToken.VALUE_NUMBER_INT || + token == JsonToken.VALUE_NUMBER_FLOAT) { + return jsonParser.getDecimalValue(); + } + return new BigDecimal(jsonParser.getText().trim()); + } + } } diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java index 5bf34ea530..266c18388e 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java @@ -368,6 +368,10 @@ private V convSingleValue(V value) { @SuppressWarnings("unchecked") V blob = (V) this.dataType().valueToBlob(value); return blob; + } else if (this.dataType().isDecimal()) { + @SuppressWarnings("unchecked") + V decimal = (V) this.dataType().valueToDecimal(value); + return decimal; } if (this.checkDataType(value)) { @@ -400,6 +404,8 @@ public interface Builder extends SchemaBuilder { Builder asLong(); + Builder asDecimal(); + Builder valueSingle(); Builder valueList(); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java index 32937a2cf0..66ae115ad4 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java @@ -602,6 +602,10 @@ private void checkSortKeys() { "The sort key '%s' must be contained in " + "properties '%s' for edge label '%s'", key, this.name, this.properties); + PropertyKey propertyKey = this.graph().propertyKey(key); + E.checkArgument(!propertyKey.dataType().isDecimal(), + "The sort key '%s' of edge label '%s' can't " + + "be a decimal property", key, this.name); } } diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java index 397df66229..50b223a63b 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java @@ -472,6 +472,9 @@ private void checkFields(Set propertyIds) { E.checkArgument(pkey.aggregateType().isIndexable(), "The aggregate type %s is not indexable", pkey.aggregateType()); + E.checkArgument(!pkey.dataType().isDecimal(), + "Not allowed to build index on property key " + + "'%s' whose data type is decimal", pkey.name()); if (pkey.cardinality().multiple()) { E.checkArgument(fields.size() == 1, diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/PropertyKeyBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/PropertyKeyBuilder.java index a50b426d1d..16ffba8e68 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/PropertyKeyBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/PropertyKeyBuilder.java @@ -273,6 +273,12 @@ public PropertyKeyBuilder asDouble() { return this; } + @Override + public PropertyKeyBuilder asDecimal() { + this.dataType = DataType.DECIMAL; + return this; + } + @Override public PropertyKeyBuilder asFloat() { this.dataType = DataType.FLOAT; @@ -427,7 +433,8 @@ private void checkAggregateType() { } if (this.aggregateType.isNumber() && - !this.dataType.isNumber() && !this.dataType.isDate()) { + !this.dataType.isNumber() && !this.dataType.isDecimal() && + !this.dataType.isDate()) { throw new NotAllowException( "Not allowed to set aggregate type '%s' for " + "property key '%s' with data type '%s'", diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java index 2bfa93e7d7..4ee24e4c80 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java @@ -17,6 +17,8 @@ package org.apache.hugegraph.type.define; +import java.math.BigDecimal; +import java.math.BigInteger; import java.nio.ByteBuffer; import java.util.Date; import java.util.List; @@ -43,7 +45,14 @@ public enum DataType implements SerialEnum { TEXT(8, "text", String.class), BLOB(9, "blob", Blob.class), DATE(10, "date", Date.class), - UUID(11, "uuid", UUID.class); + UUID(11, "uuid", UUID.class), + /* + * Arbitrary-precision decimal (java.math.BigDecimal). Stored exactly; not a + * "number" in the isNumber() sense because it has no fixed-width, sortable + * encoding, so it can't be a sort key, a range/secondary index field or an + * OLAP range property. + */ + DECIMAL(12, "decimal", BigDecimal.class); private final byte code; private final String name; @@ -103,6 +112,10 @@ public boolean isUUID() { return this == DataType.UUID; } + public boolean isDecimal() { + return this == DataType.DECIMAL; + } + public Number valueToNumber(V value) { if (!(this.isNumber() && value instanceof Number) && !JsonUtil.isInfinityOrNaN(value)) { @@ -143,6 +156,42 @@ public Number valueToNumber(V value) { return number; } + /** + * Convert a value to BigDecimal: BigDecimal as is, any other Number and a + * decimal string through their exact decimal representation. Float and + * Double go through Number.toString(), i.e. the shortest string that + * round-trips the binary value, so a client that already holds a lossy + * double gets that double, exactly. + * + * @return the BigDecimal, or null if the value is not a Number or String + * @throws IllegalArgumentException if the string is not a decimal number + */ + public BigDecimal valueToDecimal(V value) { + if (!this.isDecimal()) { + return null; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof BigInteger) { + return new BigDecimal((BigInteger) value); + } + if (value instanceof Byte || value instanceof Short || + value instanceof Integer || value instanceof Long) { + return BigDecimal.valueOf(((Number) value).longValue()); + } + if (!(value instanceof Number) && !(value instanceof String)) { + return null; + } + String text = value.toString().trim(); + try { + return new BigDecimal(text); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(String.format( + "Can't read '%s' as decimal", value)); + } + } + public Date valueToDate(V value) { if (!this.isDate()) { return null; diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java index 7321f36d98..88df1293cd 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java @@ -19,11 +19,14 @@ import java.io.IOException; +import org.apache.hugegraph.testutil.Assert; import org.junit.Before; import org.junit.Test; import jakarta.ws.rs.core.Response; +import com.google.common.collect.ImmutableMap; + public class VertexApiTest extends BaseApiTest { private static final String PATH = "/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices/"; @@ -98,4 +101,101 @@ public void testDelete() throws IOException { r = client().delete(PATH, id); assertResponseStatus(204, r); } + + @Test + public void testBatchUpdateDecimalWithSumStrategy() throws IOException { + // schema: a decimal balance on an account keyed by name + createAndAssert(URL_PREFIX + "/schema/propertykeys", + "{" + + "\"name\": \"balance\"," + + "\"data_type\": \"DECIMAL\"," + + "\"cardinality\": \"SINGLE\"," + + "\"check_exist\": false," + + "\"properties\":[]" + + "}", 202); + createAndAssert(URL_PREFIX + "/schema/vertexlabels", + "{" + + "\"primary_keys\":[\"name\"]," + + "\"id_strategy\": \"PRIMARY_KEY\"," + + "\"name\": \"account\"," + + "\"properties\":[\"name\", \"balance\"]," + + "\"check_exist\": false," + + "\"nullable_keys\":[\"balance\"]" + + "}"); + + // 2^256 - 2, as a string + String almostMax = "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639934"; + String max = "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"; + String vertex = "{" + + "\"label\":\"account\"," + + "\"properties\":{" + + "\"name\":\"alice\"," + + "\"balance\":\"" + almostMax + "\"}" + + "}"; + Response r = client().post(PATH, vertex); + String content = assertResponseStatus(201, r); + String id = parseId(content); + Assert.assertContains("\"balance\":\"" + almostMax + "\"", content); + + // SUM through the batch update: the server adds exactly; an integral + // JSON number literal is accepted as the increment + String batch = "{" + + "\"vertices\":[{" + + "\"label\":\"account\"," + + "\"properties\":{" + + "\"name\":\"alice\"," + + "\"balance\":1}" + + "}]," + + "\"update_strategies\":{\"balance\":\"SUM\"}," + + "\"create_if_not_exist\":true" + + "}"; + r = client().put(PATH, "batch", batch, ImmutableMap.of()); + content = assertResponseStatus(200, r); + Assert.assertContains("\"balance\":\"" + max + "\"", content); + + // a fraction is sent as a string (a JSON fraction literal would be a + // double to the parser); two entries for the same vertex in one + // request are combined first, then added to the stored value + batch = "{" + + "\"vertices\":[{" + + "\"label\":\"account\"," + + "\"properties\":{" + + "\"name\":\"alice\"," + + "\"balance\":\"0.000000000000000000\"}" + + "},{" + + "\"label\":\"account\"," + + "\"properties\":{" + + "\"name\":\"alice\"," + + "\"balance\":\"0.000000000000000001\"}" + + "}]," + + "\"update_strategies\":{\"balance\":\"SUM\"}," + + "\"create_if_not_exist\":true" + + "}"; + r = client().put(PATH, "batch", batch, ImmutableMap.of()); + content = assertResponseStatus(200, r); + String expected = max + ".000000000000000001"; + Assert.assertContains("\"balance\":\"" + expected + "\"", content); + + // read back through GET + r = client().get(PATH, String.format("\"%s\"", id)); + content = assertResponseStatus(200, r); + Assert.assertContains("\"balance\":\"" + expected + "\"", content); + + // BIGGER keeps the larger of the two, compared as decimals + batch = "{" + + "\"vertices\":[{" + + "\"label\":\"account\"," + + "\"properties\":{" + + "\"name\":\"alice\"," + + "\"balance\":\"" + almostMax + "\"}" + + "}]," + + "\"update_strategies\":{\"balance\":\"BIGGER\"}," + + "\"create_if_not_exist\":true" + + "}"; + r = client().put(PATH, "batch", batch, ImmutableMap.of()); + content = assertResponseStatus(200, r); + Assert.assertContains("\"balance\":\"" + expected + "\"", content); + } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/EdgeLabelCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/EdgeLabelCoreTest.java index 8629f78b3e..0aa45454ce 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/EdgeLabelCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/EdgeLabelCoreTest.java @@ -1500,4 +1500,34 @@ public void testDuplicateEdgeLabelWithDifferentProperties() { .create(); }); } + + @Test + public void testAddEdgeLabelWithDecimalSortKey() { + super.initPropertyKeys(); + SchemaManager schema = graph().schema(); + schema.propertyKey("amount").asDecimal().create(); + schema.vertexLabel("account") + .properties("name") + .primaryKeys("name") + .create(); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + schema.edgeLabel("transfer").multiTimes() + .properties("amount", "time") + .link("account", "account") + .sortKeys("amount") + .create(); + }, e -> { + Assert.assertContains("can't be a decimal property", + e.getMessage()); + }); + + // a decimal is fine as an ordinary edge property + EdgeLabel transfer = schema.edgeLabel("transfer").multiTimes() + .properties("amount", "time") + .link("account", "account") + .sortKeys("time") + .create(); + Assert.assertEquals(1, transfer.sortKeys().size()); + } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/IndexLabelCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/IndexLabelCoreTest.java index 24a905427c..0e0d377468 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/IndexLabelCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/IndexLabelCoreTest.java @@ -1882,4 +1882,41 @@ public void testDuplicateIndexLabelWithDifferentProperties() { .create(); }); } + + @Test + public void testAddIndexLabelOnDecimalProperty() { + super.initPropertyKeys(); + SchemaManager schema = graph().schema(); + schema.propertyKey("balance").asDecimal().create(); + schema.vertexLabel("account") + .properties("name", "balance") + .primaryKeys("name") + .create(); + + // no byte-order encoding exists for decimals: no index of any type + Assert.assertThrows(IllegalArgumentException.class, () -> { + schema.indexLabel("accountByBalance").onV("account") + .by("balance").secondary().create(); + }, e -> { + Assert.assertContains("data type is decimal", e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + schema.indexLabel("accountByBalanceRange").onV("account") + .by("balance").range().create(); + }, e -> { + Assert.assertContains("data type is decimal", e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + schema.indexLabel("accountByNameBalance").onV("account") + .by("name", "balance").shard().create(); + }, e -> { + Assert.assertContains("data type is decimal", e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + schema.indexLabel("accountByBalanceUnique").onV("account") + .by("balance").unique().create(); + }, e -> { + Assert.assertContains("data type is decimal", e.getMessage()); + }); + } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java index 0609f607ba..6dcfbaa3a8 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.core; +import java.math.BigDecimal; import java.util.Date; import org.apache.hugegraph.HugeException; @@ -741,4 +742,51 @@ public void testDuplicatePropertyKeyWithDifferentProperties() { .create(); }); } + + @Test + public void testAddPropertyKeyWithDecimalType() { + SchemaManager schema = graph().schema(); + PropertyKey balance = schema.propertyKey("balance") + .asDecimal() + .valueSingle() + .create(); + + Assert.assertEquals("balance", balance.name()); + Assert.assertEquals(DataType.DECIMAL, balance.dataType()); + Assert.assertEquals(Cardinality.SINGLE, balance.cardinality()); + Assert.assertEquals(DataType.DECIMAL, + graph().propertyKey("balance").dataType()); + + // values are normalised to BigDecimal, exactly + String uint256Max = "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"; + Assert.assertEquals(new BigDecimal(uint256Max), + balance.validValue(uint256Max)); + Assert.assertEquals(new BigDecimal("42"), balance.validValue(42L)); + Assert.assertEquals(new BigDecimal("0.1"), balance.validValue(0.1D)); + Assert.assertNull(balance.validValue(true)); + Assert.assertThrows(IllegalArgumentException.class, () -> { + balance.validValue("1,5"); + }, e -> { + Assert.assertContains("Can't read '1,5' as decimal", + e.getMessage()); + }); + + // SUM/MAX/MIN aggregate types are allowed like on any numeric key + PropertyKey total = schema.propertyKey("total") + .asDecimal() + .calcSum() + .create(); + Assert.assertEquals(AggregateType.SUM, total.aggregateType()); + + // decimal lists and sets + PropertyKey amounts = schema.propertyKey("amounts") + .asDecimal() + .valueList() + .create(); + Assert.assertEquals(Cardinality.LIST, amounts.cardinality()); + Assert.assertEquals(ImmutableList.of(new BigDecimal("1"), + new BigDecimal("2.5")), + amounts.validValue(ImmutableList.of("1", "2.5"))); + } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/VertexCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/VertexCoreTest.java index 80c4aef50b..630ef9d45d 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/VertexCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/VertexCoreTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.core; +import java.math.BigInteger; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; @@ -9631,4 +9632,90 @@ private static void assertNotContains(List vertices, Object... keyValues) { Assert.assertFalse(Utils.contains(vertices, new FakeObjects.FakeVertex(keyValues))); } + + @Test + public void testAddVertexWithPropertyValueOfDecimal() { + HugeGraph graph = graph(); + + SchemaManager schema = graph.schema(); + schema.propertyKey("balance").asDecimal().create(); + schema.vertexLabel("account").properties("balance").create(); + + // 2^256 - 1: exact through the write path, the backend and the read path + String uint256Max = "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"; + BigDecimal expected = new BigDecimal(uint256Max); + Vertex vertex = graph.addVertex(T.label, "account", + "balance", uint256Max); + Assert.assertEquals(expected, vertex.value("balance")); + graph.tx().commit(); + + Vertex loaded = graph.vertex(vertex.id()); + Assert.assertEquals(expected, loaded.value("balance")); + Assert.assertEquals(BigDecimal.class, loaded.value("balance").getClass()); + + // a wei above an ether, in ether: 18 fraction digits kept + BigDecimal wei = new BigDecimal("1.000000000000000001"); + Vertex v2 = graph.addVertex(T.label, "account", "balance", wei); + Vertex v3 = graph.addVertex(T.label, "account", "balance", 42L); + Vertex v4 = graph.addVertex(T.label, "account", + "balance", new BigInteger(uint256Max)); + graph.tx().commit(); + Assert.assertEquals(wei, graph.vertex(v2.id()).value("balance")); + Assert.assertEquals(new BigDecimal("42"), + graph.vertex(v3.id()).value("balance")); + Assert.assertEquals(expected, graph.vertex(v4.id()).value("balance")); + + // equality is exact, not through double: the neighbouring value + // (2^256 - 2) is a different number. Filter by id so no index is + // needed; the condition is evaluated on the server + BigDecimal neighbour = expected.subtract(BigDecimal.ONE); + Object[] ids = {vertex.id(), v2.id(), v3.id(), v4.id()}; + Assert.assertEquals(2L, graph.traversal().V(ids) + .has("balance", expected) + .count().next().longValue()); + Assert.assertEquals(0L, graph.traversal().V(ids) + .has("balance", neighbour) + .count().next().longValue()); + // ranges: 42 and both uint256 values are above 1.000000000000000001 + Assert.assertEquals(3L, graph.traversal().V(ids) + .has("balance", P.gt(wei)) + .count().next().longValue()); + Assert.assertEquals(0L, graph.traversal().V(ids) + .has("balance", P.lt(wei)) + .count().next().longValue()); + Assert.assertEquals(1L, graph.traversal().V(ids) + .has("balance", P.lt(new BigDecimal("42"))) + .count().next().longValue()); + Assert.assertEquals(2L, graph.traversal().V(ids) + .has("balance", P.gte(neighbour)) + .count().next().longValue()); + + // updates keep the exact value + loaded.property("balance", neighbour); + graph.tx().commit(); + Assert.assertEquals(neighbour, graph.vertex(vertex.id()).value("balance")); + } + + @Test + public void testAddVertexWithInvalidPropertyValueOfDecimal() { + HugeGraph graph = graph(); + + SchemaManager schema = graph.schema(); + schema.propertyKey("balance").asDecimal().create(); + schema.vertexLabel("account").properties("balance").create(); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + graph.addVertex(T.label, "account", "balance", "12abc"); + }, e -> { + Assert.assertContains("Can't read '12abc' as decimal", + e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + graph.addVertex(T.label, "account", "balance", true); + }, e -> { + Assert.assertContains("Invalid property value 'true' " + + "for key 'balance'", e.getMessage()); + }); + } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java index 3ccff1a131..09e9ca73d0 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java @@ -17,6 +17,8 @@ package org.apache.hugegraph.unit.core; +import java.math.BigInteger; +import java.math.BigDecimal; import java.util.Date; import java.util.UUID; @@ -40,12 +42,15 @@ public void testString() { Assert.assertEquals("blob", DataType.BLOB.string()); Assert.assertEquals("date", DataType.DATE.string()); Assert.assertEquals("uuid", DataType.UUID.string()); + Assert.assertEquals("decimal", DataType.DECIMAL.string()); } @Test public void testValueToNumber() { Assert.assertNull(DataType.BOOLEAN.valueToNumber(1)); Assert.assertNull(DataType.INT.valueToNumber("not number")); + // decimal is not a "number" in the fixed-width sense + Assert.assertNull(DataType.DECIMAL.valueToNumber(1)); Assert.assertEquals((byte) 1, DataType.BYTE.valueToNumber(1)); Assert.assertEquals(1, DataType.INT.valueToNumber(1)); @@ -82,4 +87,74 @@ public void testValueToUUID() { Assert.assertNull(DataType.TEXT.valueToUUID("2019-01-01 12:00:00")); Assert.assertNull(DataType.UUID.valueToUUID(true)); } + + @Test + public void testDecimal() { + Assert.assertTrue(DataType.DECIMAL.isDecimal()); + Assert.assertFalse(DataType.DECIMAL.isNumber()); + Assert.assertFalse(DataType.DECIMAL.isNumber4()); + Assert.assertFalse(DataType.DECIMAL.isNumber8()); + Assert.assertFalse(DataType.DOUBLE.isDecimal()); + Assert.assertEquals(BigDecimal.class, DataType.DECIMAL.clazz()); + Assert.assertEquals(DataType.DECIMAL, + DataType.fromClass(BigDecimal.class)); + } + + @Test + public void testValueToDecimal() { + // uint256 max: 78 digits, far beyond long and double + String uint256Max = "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"; + BigDecimal expected = new BigDecimal(uint256Max); + Assert.assertSame(expected, DataType.DECIMAL.valueToDecimal(expected)); + Assert.assertEquals(expected, + DataType.DECIMAL.valueToDecimal(uint256Max)); + Assert.assertEquals(expected, DataType.DECIMAL.valueToDecimal( + new BigInteger(uint256Max))); + Assert.assertEquals(uint256Max, DataType.DECIMAL.valueToDecimal( + " " + uint256Max + " ").toPlainString()); + + // scale is preserved: 1 wei on top of 1 ether, in ether + BigDecimal wei = DataType.DECIMAL.valueToDecimal( + "1.000000000000000001"); + Assert.assertEquals(18, wei.scale()); + Assert.assertEquals("1.000000000000000001", wei.toPlainString()); + + // integral java numbers are exact + Assert.assertEquals(new BigDecimal("42"), + DataType.DECIMAL.valueToDecimal(42)); + Assert.assertEquals(new BigDecimal("42"), + DataType.DECIMAL.valueToDecimal(42L)); + Assert.assertEquals(new BigDecimal("-7"), + DataType.DECIMAL.valueToDecimal((byte) -7)); + // binary floats arrive as their shortest decimal representation + Assert.assertEquals(new BigDecimal("0.1"), + DataType.DECIMAL.valueToDecimal(0.1D)); + Assert.assertEquals(new BigDecimal("1.5"), + DataType.DECIMAL.valueToDecimal(1.5F)); + // negative and zero + Assert.assertEquals(new BigDecimal("-0.5"), + DataType.DECIMAL.valueToDecimal("-0.5")); + Assert.assertEquals(BigDecimal.ZERO, + DataType.DECIMAL.valueToDecimal("0")); + + // not convertible + Assert.assertNull(DataType.DECIMAL.valueToDecimal(true)); + Assert.assertNull(DataType.DECIMAL.valueToDecimal(new Date())); + Assert.assertNull(DataType.TEXT.valueToDecimal("1.5")); + Assert.assertNull(DataType.DOUBLE.valueToDecimal(1.5D)); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + DataType.DECIMAL.valueToDecimal("12abc"); + }, e -> { + Assert.assertContains("Can't read '12abc' as decimal", + e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + DataType.DECIMAL.valueToDecimal(""); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + DataType.DECIMAL.valueToDecimal("0x10"); + }); + } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/BytesBufferTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/BytesBufferTest.java index 8d82a7c6c8..0ab08f495d 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/BytesBufferTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/BytesBufferTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.unit.serializer; +import java.math.BigDecimal; import java.awt.Point; import java.lang.reflect.Field; import java.util.Arrays; @@ -1024,6 +1025,36 @@ public void testProperty() { Assert.assertArrayEquals(bytes, buf.writeProperty(pkey, value).bytes()); Assert.assertEquals(value, BytesBuffer.wrap(bytes).readProperty(pkey)); + // decimal = vint(len) + two's-complement unscaled bytes + vint(scale) + pkey = genPkey(DataType.DECIMAL); + value = new BigDecimal("-1.5"); // unscaled -15 (0xf1), scale 1 + bytes = genBytes("01f101"); + buf.forReadWritten(); + Assert.assertArrayEquals(bytes, buf.writeProperty(pkey, value).bytes()); + Assert.assertEquals(value, BytesBuffer.wrap(bytes).readProperty(pkey)); + + value = BigDecimal.ZERO; + bytes = genBytes("010000"); + buf.forReadWritten(); + Assert.assertArrayEquals(bytes, buf.writeProperty(pkey, value).bytes()); + Assert.assertEquals(value, BytesBuffer.wrap(bytes).readProperty(pkey)); + + // uint256 max: 33 bytes (sign byte + 32 × 0xff), scale 0 + value = new BigDecimal("115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"); + bytes = genBytes("2100" + "ff".repeat(32) + "00"); + buf.forReadWritten(); + Assert.assertArrayEquals(bytes, buf.writeProperty(pkey, value).bytes()); + Assert.assertEquals(value, BytesBuffer.wrap(bytes).readProperty(pkey)); + + // scale survives the round trip (1 wei above 1 ether, in ether) + value = new BigDecimal("1.000000000000000001"); + buf.forReadWritten(); + bytes = buf.writeProperty(pkey, value).bytes(); + Object read = BytesBuffer.wrap(bytes).readProperty(pkey); + Assert.assertEquals(value, read); + Assert.assertEquals(18, ((BigDecimal) read).scale()); + pkey = genPkey(DataType.OBJECT); value = new Point(3, 8); bytes = genBytes("1301006a6176612e6177742e506f696ef4010610"); @@ -1133,6 +1164,13 @@ public void testPropertyWithList() { Assert.assertArrayEquals(bytes, buf.writeProperty(pkey, value).bytes()); Assert.assertEquals(value, BytesBuffer.wrap(bytes).readProperty(pkey)); + pkey = genListPkey(DataType.DECIMAL); + value = ImmutableList.of(new BigDecimal("0.1"), new BigDecimal("1e21"), + new BigDecimal("-0.000000000000000001")); + buf.forReadWritten(); + bytes = buf.writeProperty(pkey, value).bytes(); + Assert.assertEquals(value, BytesBuffer.wrap(bytes).readProperty(pkey)); + pkey = genListPkey(DataType.OBJECT); value = ImmutableList.of(new Point(3, 8), new Point(3, 9)); bytes = genBytes("021301006a6176612e6177742e506f696ef4010610" + diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java index c76d536ff0..596b364691 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.unit.util; +import java.math.BigDecimal; import java.util.Arrays; import java.util.Collections; import java.util.Date; @@ -54,6 +55,8 @@ import org.apache.tinkerpop.shaded.jackson.core.type.TypeReference; import org.eclipse.collections.api.map.primitive.MutableIntObjectMap; import org.junit.Test; + +import com.google.common.collect.ImmutableMap; import org.mockito.Mockito; import com.google.common.collect.ImmutableList; @@ -316,4 +319,27 @@ public void testDeserializeList() { Assert.assertEquals(ImmutableList.of(1, 2, 3), JsonUtil.fromJson(json, typeRef)); } + + @Test + public void testSerializeBigDecimal() { + // decimals travel as plain strings, never as JSON numbers + BigDecimal decimal = new BigDecimal("1e21"); + Assert.assertEquals("\"1000000000000000000000\"", + JsonUtil.toJson(decimal)); + Assert.assertEquals("\"0.000000000000000001\"", + JsonUtil.toJson(new BigDecimal("1E-18"))); + Assert.assertEquals("\"-1.50\"", + JsonUtil.toJson(new BigDecimal("-1.50"))); + Assert.assertEquals("{\"balance\":\"1000000000000000000000\"}", + JsonUtil.toJson(ImmutableMap.of("balance", decimal))); + + // both a string and a number literal are accepted on the way in + Assert.assertEquals(new BigDecimal("1.5"), + JsonUtil.fromJson("\"1.5\"", BigDecimal.class)); + Assert.assertEquals(new BigDecimal("1.5"), + JsonUtil.fromJson("1.5", BigDecimal.class)); + Assert.assertEquals(new BigDecimal("1000000000000000000000"), + JsonUtil.fromJson("1000000000000000000000", + BigDecimal.class)); + } } diff --git a/hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/business/GraphStoreIterator.java b/hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/business/GraphStoreIterator.java index 51b9c8d15c..b4882fc8b0 100644 --- a/hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/business/GraphStoreIterator.java +++ b/hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/business/GraphStoreIterator.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.store.business; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Date; import java.util.HashSet; @@ -255,6 +256,10 @@ private

> List buildProperties( variant.setType(VariantType.VT_DOUBLE) .setValueDouble((Double) v); break; + case DECIMAL: + variant.setType(VariantType.VT_STRING) + .setValueString(((BigDecimal) v).toPlainString()); + break; case OBJECT: case UNKNOWN: variant.setType(VariantType.VT_UNKNOWN) diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/serializer/BytesBuffer.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/serializer/BytesBuffer.java index 4ec6aad194..5cb8e0293b 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/serializer/BytesBuffer.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/serializer/BytesBuffer.java @@ -19,6 +19,8 @@ package org.apache.hugegraph.serializer; +import java.math.BigDecimal; +import java.math.BigInteger; import java.io.OutputStream; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -643,6 +645,13 @@ public void writeProperty(DataType dataType, Object value) { this.writeLong(uuid.getMostSignificantBits()); this.writeLong(uuid.getLeastSignificantBits()); break; + case DECIMAL: + // unscaled two's-complement bytes + scale: exact for any + // precision, 33 bytes for a 78-digit (uint256) value + BigDecimal decimal = (BigDecimal) value; + this.writeBytes(decimal.unscaledValue().toByteArray()); + this.writeVInt(decimal.scale()); + break; default: throw new IllegalArgumentException("Unsupported data type " + dataType); } @@ -670,6 +679,9 @@ public Object readProperty(DataType dataType) { return Blob.wrap(this.readBigBytes()); case UUID: return new UUID(this.readLong(), this.readLong()); + case DECIMAL: + BigInteger unscaled = new BigInteger(this.readBytes()); + return new BigDecimal(unscaled, this.readVInt()); default: throw new IllegalArgumentException("Unsupported data type " + dataType); } diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java index 81dae36697..6b7b0a5ab1 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java @@ -424,6 +424,9 @@ public String convert2Groovy(boolean attachIdFlag) { case UUID: builder.append(".asUUID()"); break; + case DECIMAL: + builder.append(".asDecimal()"); + break; case OBJECT: builder.append(".asObject()"); break; diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java index 6a04a83034..6ec738bd53 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java @@ -19,6 +19,7 @@ package org.apache.hugegraph.type.define; +import java.math.BigDecimal; import java.nio.ByteBuffer; import java.util.Date; import java.util.List; @@ -46,7 +47,12 @@ public enum DataType implements SerialEnum { TEXT(8, "text", String.class), BLOB(9, "blob", Blob.class), DATE(10, "date", Date.class), - UUID(11, "uuid", UUID.class); + UUID(11, "uuid", UUID.class), + /* + * Arbitrary-precision decimal (java.math.BigDecimal), see the server copy + * of this enum: exact, but not a sort key / index / OLAP range type. + */ + DECIMAL(12, "decimal", BigDecimal.class); private final byte code; private final String name; @@ -109,6 +115,10 @@ public boolean isUUID() { return this == DataType.UUID; } + public boolean isDecimal() { + return this == DataType.DECIMAL; + } + public Number valueToNumber(V value) { if (!(this.isNumber() && value instanceof Number) && !(value instanceof String && SPECIAL_FLOATS.contains(value))) { diff --git a/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java b/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java index d8441144f2..c93f946660 100644 --- a/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java +++ b/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java @@ -17,11 +17,13 @@ package org.apache.hugegraph.struct.schema; +import java.math.BigDecimal; import java.util.Arrays; import java.util.Date; import java.util.Set; import org.apache.hugegraph.id.IdGenerator; +import org.apache.hugegraph.serializer.BytesBuffer; import org.apache.hugegraph.type.define.Cardinality; import org.apache.hugegraph.type.define.DataType; import org.apache.hugegraph.util.DateUtil; @@ -67,4 +69,31 @@ public void testSetDefaultValueCollapsesDuplicatesAndReturnsSet() { Assert.assertEquals(1, values.size()); Assert.assertTrue(values.contains(DateUtil.parse(formatted))); } + + @Test + public void testDecimalPropertyRoundTripAndSchema() { + PropertyKey propertyKey = new PropertyKey(null, IdGenerator.of(2), + "balance"); + propertyKey.dataType(DataType.DECIMAL); + Assert.assertTrue(propertyKey.dataType().isDecimal()); + Assert.assertFalse(propertyKey.dataType().isNumber()); + Assert.assertTrue(propertyKey.convert2Groovy(false).contains(".asDecimal()")); + + // uint256 max survives the struct BytesBuffer used by the store + BigDecimal value = new BigDecimal( + "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"); + BytesBuffer buffer = BytesBuffer.allocate(64); + buffer.writeProperty(DataType.DECIMAL, value); + Object read = BytesBuffer.wrap(buffer.bytes()) + .readProperty(DataType.DECIMAL); + Assert.assertEquals(value, read); + + BigDecimal wei = new BigDecimal("1.000000000000000001"); + buffer = BytesBuffer.allocate(64); + buffer.writeProperty(DataType.DECIMAL, wei); + read = BytesBuffer.wrap(buffer.bytes()).readProperty(DataType.DECIMAL); + Assert.assertEquals(wei, read); + Assert.assertEquals(18, ((BigDecimal) read).scale()); + } } From 9d5eaab793d503e9db83e4a971709c9a669251b6 Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Wed, 16 Sep 2026 13:52:08 +0000 Subject: [PATCH 02/11] feat(core): review round 1 for DECIMAL: struct conversion, typed GraphSON, OLAP guard - hugegraph-struct: port DataType.valueToDecimal(), add the decimal branch to PropertyKey.convSingleValue() and Builder.asDecimal(); tests for string, integral, BigInteger and default-value input - HugeGraphSONModule: implement BigDecimalSerializer.serializeWithType() for the typed GraphSON v2/v3 mappers; keep TinkerPop's gx:BigDecimal type id, carry the plain string in @value; HugeGraphSONModuleTest round-trips through GraphSONMessageSerializerV1d0/V2d0/V3d0 with HugeGraphIoRegistry - PropertyKeyBuilder.checkOlap(): reject OLAP_SECONDARY/OLAP_RANGE for DECIMAL; IndexLabelBuilder.build(): guard on every path, not only create(); core test for the OLAP path --- .../hugegraph/io/HugeGraphSONModule.java | 21 +++- .../schema/builder/IndexLabelBuilder.java | 6 + .../schema/builder/PropertyKeyBuilder.java | 10 ++ .../hugegraph/core/PropertyKeyCoreTest.java | 35 ++++++ .../apache/hugegraph/unit/UnitTestSuite.java | 2 + .../serializer/HugeGraphSONModuleTest.java | 111 ++++++++++++++++++ .../hugegraph/struct/schema/PropertyKey.java | 6 + .../hugegraph/type/define/DataType.java | 27 +++++ .../struct/schema/PropertyKeyTest.java | 68 +++++++++++ 9 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/HugeGraphSONModuleTest.java diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java index 8db7b25fec..ef36d72ed3 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java @@ -106,7 +106,6 @@ public class HugeGraphSONModule extends TinkerPopJacksonModule { TYPE_DEFINITIONS.put(Optional.class, "Optional"); TYPE_DEFINITIONS.put(Date.class, "Date"); TYPE_DEFINITIONS.put(UUID.class, "UUID"); - TYPE_DEFINITIONS.put(BigDecimal.class, "BigDecimal"); // HugeGraph id serializer TYPE_DEFINITIONS.put(StringId.class, "StringId"); @@ -974,6 +973,26 @@ public void serialize(BigDecimal decimal, JsonGenerator jsonGenerator, SerializerProvider provider) throws IOException { jsonGenerator.writeString(decimal.toPlainString()); } + + @Override + public void serializeWithType(BigDecimal decimal, + JsonGenerator jsonGenerator, + SerializerProvider provider, + TypeSerializer typeSer) + throws IOException { + /* + * The typed GraphSON mappers (v2/v3) call this variant and + * StdSerializer does not implement it. Keep the type prefix so + * that the value stays "gx:BigDecimal", but carry the plain + * string inside it: a JSON number would be read as a double by + * most clients, which is what this type exists to avoid. + */ + WritableTypeId typeId = typeSer.typeId(decimal, + JsonToken.VALUE_STRING); + typeSer.writeTypePrefix(jsonGenerator, typeId); + this.serialize(decimal, jsonGenerator, provider); + typeSer.writeTypeSuffix(jsonGenerator, typeId); + } } private static class BigDecimalDeserializer extends StdDeserializer { diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java index 50b223a63b..7492d30fe1 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java @@ -114,6 +114,12 @@ public IndexLabel build() { indexLabel.indexType(this.indexType); for (String field : this.indexFields) { PropertyKey propertyKey = graph.propertyKey(field); + // Also guarded in checkFields(), but build() is reached directly + // by the OLAP property-key path, which skips checkFields() + E.checkArgument(!propertyKey.dataType().isDecimal(), + "Not allowed to build index on property key " + + "'%s' whose data type is decimal", + propertyKey.name()); indexLabel.indexField(propertyKey.id()); } indexLabel.userdata(this.userdata); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/PropertyKeyBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/PropertyKeyBuilder.java index 16ffba8e68..1a418342f0 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/PropertyKeyBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/PropertyKeyBuilder.java @@ -459,6 +459,16 @@ private void checkOlap() { "property key '%s'", this.aggregateType, this.name); } + if (this.dataType.isDecimal() && + this.writeType != WriteType.OLAP_COMMON) { + // OLAP_SECONDARY / OLAP_RANGE build an index label on the key, + // and no index of any type is allowed on a decimal + throw new NotAllowException( + "Not allowed to set write type to %s for property key " + + "'%s' with data type '%s': decimal keys can't be indexed", + this.writeType, this.name, this.dataType); + } + if (this.writeType == WriteType.OLAP_RANGE && !this.dataType.isNumber() && !this.dataType.isDate()) { throw new NotAllowException( diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java index 6dcfbaa3a8..61625ccc94 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java @@ -392,6 +392,41 @@ public void testAddOlapPropertyKey() { Assert.assertEquals(WriteType.OLAP_SECONDARY, wcc.writeType()); } + @Test + public void testAddOlapPropertyKeyWithDecimalType() { + Assume.assumeTrue("Not support olap properties", + storeFeatures().supportsOlapProperties()); + + SchemaManager schema = graph().schema(); + + // OLAP_SECONDARY and OLAP_RANGE build an index label on the key + // through SchemaTransaction.createIndexLabelForOlapPk(), which + // skips IndexLabelBuilder.checkFields(): the rule "no index of any + // type on a decimal" has to hold there too + Assert.assertThrows(NotAllowException.class, () -> { + schema.propertyKey("rank").asDecimal().valueSingle() + .writeType(WriteType.OLAP_SECONDARY).create(); + }, e -> { + Assert.assertContains("decimal keys can't be indexed", + e.getMessage()); + }); + Assert.assertThrows(NotAllowException.class, () -> { + schema.propertyKey("rank").asDecimal().valueSingle() + .writeType(WriteType.OLAP_RANGE).create(); + }, e -> { + Assert.assertContains("decimal keys can't be indexed", + e.getMessage()); + }); + Assert.assertFalse(graph().existsIndexLabel("*olap_by_rank")); + + // OLAP_COMMON has no index and stays allowed + PropertyKey rank = schema.propertyKey("rank").asDecimal() + .valueSingle() + .writeType(WriteType.OLAP_COMMON).create(); + Assert.assertEquals(DataType.DECIMAL, rank.dataType()); + Assert.assertEquals(WriteType.OLAP_COMMON, rank.writeType()); + } + @Test public void testClearOlapPropertyKey() { Assume.assumeTrue("Not support olap properties", diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index d48738b840..0b002852a4 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -81,6 +81,7 @@ import org.apache.hugegraph.unit.serializer.BinaryScatterSerializerTest; import org.apache.hugegraph.unit.serializer.BinarySerializerTest; import org.apache.hugegraph.unit.serializer.BytesBufferTest; +import org.apache.hugegraph.unit.serializer.HugeGraphSONModuleTest; import org.apache.hugegraph.unit.serializer.SerializerFactoryTest; import org.apache.hugegraph.unit.serializer.StoreSerializerTest; import org.apache.hugegraph.unit.serializer.TableBackendEntryTest; @@ -189,6 +190,7 @@ BinaryScatterSerializerTest.class, StoreSerializerTest.class, TextSerializerTest.class, + HugeGraphSONModuleTest.class, /* rocksdb */ RocksDBSessionsTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/HugeGraphSONModuleTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/HugeGraphSONModuleTest.java new file mode 100644 index 0000000000..ea01f3f432 --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/HugeGraphSONModuleTest.java @@ -0,0 +1,111 @@ +/* + * 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.unit.serializer; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.apache.hugegraph.io.HugeGraphIoRegistry; +import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.unit.BaseUnitTest; +import org.apache.tinkerpop.gremlin.driver.message.ResponseMessage; +import org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerV1d0; +import org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerV2d0; +import org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerV3d0; +import org.apache.tinkerpop.gremlin.driver.ser.MessageTextSerializer; +import org.apache.tinkerpop.gremlin.driver.ser.SerializationException; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +/** + * The module is registered into the gremlin-server GraphSON mappers through + * HugeGraphIoRegistry (gremlin-server.yaml: ioRegistries). The typed + * mappers (v2/v3) call serializeWithType(), so every serializer added by + * the module has to implement it or Gremlin results of that type fail. + */ +public class HugeGraphSONModuleTest extends BaseUnitTest { + + private static final Map CONFIG = ImmutableMap.of( + "ioRegistries", + ImmutableList.of(HugeGraphIoRegistry.class.getName())); + + private static final BigDecimal DECIMAL = new BigDecimal("1.5"); + private static final BigDecimal WEI = new BigDecimal( + "0.000000000000000001"); + private static final BigDecimal UINT256_MAX = new BigDecimal( + "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"); + + private static ResponseMessage response(Object... results) { + return ResponseMessage.build(UUID.randomUUID()) + .result(ImmutableList.copyOf(results)) + .create(); + } + + private static Object firstResult(ResponseMessage message) { + @SuppressWarnings("unchecked") + List data = (List) message.getResult().getData(); + return data.get(0); + } + + @Test + public void testBigDecimalThroughGraphSONV1() throws Exception { + GraphSONMessageSerializerV1d0 serializer = + new GraphSONMessageSerializerV1d0(); + serializer.configure(CONFIG, null); + + String json = serializer.serializeResponseAsString(response(DECIMAL)); + Assert.assertContains("\"1.5\"", json); + } + + @Test + public void testBigDecimalThroughGraphSONV2() throws Exception { + GraphSONMessageSerializerV2d0 serializer = + new GraphSONMessageSerializerV2d0(); + serializer.configure(CONFIG, null); + this.assertTypedRoundTrip(serializer); + } + + @Test + public void testBigDecimalThroughGraphSONV3() throws Exception { + GraphSONMessageSerializerV3d0 serializer = + new GraphSONMessageSerializerV3d0(); + serializer.configure(CONFIG, null); + this.assertTypedRoundTrip(serializer); + } + + private void assertTypedRoundTrip(MessageTextSerializer serializer) + throws SerializationException { + for (BigDecimal value : ImmutableList.of(DECIMAL, WEI, UINT256_MAX)) { + String json = serializer.serializeResponseAsString( + response(value)); + // the type prefix survives, the value travels as a plain string + Assert.assertContains("gx:BigDecimal", json); + Assert.assertContains("\"" + value.toPlainString() + "\"", json); + + ResponseMessage read = serializer.deserializeResponse(json); + Object result = firstResult(read); + Assert.assertEquals(BigDecimal.class, result.getClass()); + Assert.assertEquals(value, result); + } + } +} diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java index 6b7b0a5ab1..88efac143b 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java @@ -373,6 +373,10 @@ private V convSingleValue(V value) { @SuppressWarnings("unchecked") V blob = (V) this.dataType().valueToBlob(value); return blob; + } else if (this.dataType().isDecimal()) { + @SuppressWarnings("unchecked") + V decimal = (V) this.dataType().valueToDecimal(value); + return decimal; } if (this.checkDataType(value)) { @@ -551,6 +555,8 @@ public interface Builder extends SchemaBuilder { Builder asLong(); + Builder asDecimal(); + Builder valueSingle(); Builder valueList(); diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java index 6ec738bd53..9a9b8a82d4 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java @@ -20,6 +20,7 @@ package org.apache.hugegraph.type.define; import java.math.BigDecimal; +import java.math.BigInteger; import java.nio.ByteBuffer; import java.util.Date; import java.util.List; @@ -159,6 +160,32 @@ public Number valueToNumber(V value) { return number; } + public BigDecimal valueToDecimal(V value) { + if (!this.isDecimal()) { + return null; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof BigInteger) { + return new BigDecimal((BigInteger) value); + } + if (value instanceof Byte || value instanceof Short || + value instanceof Integer || value instanceof Long) { + return BigDecimal.valueOf(((Number) value).longValue()); + } + if (!(value instanceof Number) && !(value instanceof String)) { + return null; + } + String text = value.toString().trim(); + try { + return new BigDecimal(text); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(String.format( + "Can't read '%s' as decimal", value)); + } + } + public Date valueToDate(V value) { if (!this.isDate()) { return null; diff --git a/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java b/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java index c93f946660..cbb1a2bcd8 100644 --- a/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java +++ b/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java @@ -18,7 +18,9 @@ package org.apache.hugegraph.struct.schema; import java.math.BigDecimal; +import java.math.BigInteger; import java.util.Arrays; +import java.util.List; import java.util.Date; import java.util.Set; @@ -96,4 +98,70 @@ public void testDecimalPropertyRoundTripAndSchema() { Assert.assertEquals(wei, read); Assert.assertEquals(18, ((BigDecimal) read).scale()); } + + @Test + public void testDecimalValueConversion() { + // The struct copy must convert the same inputs as the server copy: + // strings (userdata and JSON), integral numbers, BigInteger + PropertyKey propertyKey = new PropertyKey(null, IdGenerator.of(3), + "balance"); + propertyKey.dataType(DataType.DECIMAL); + + Assert.assertEquals(new BigDecimal("1.5"), + propertyKey.validValueOrThrow("1.5")); + Assert.assertEquals(new BigDecimal("42"), + propertyKey.validValueOrThrow(42L)); + Assert.assertEquals(new BigDecimal("7"), + propertyKey.validValueOrThrow(7)); + String uint256Max = "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"; + Assert.assertEquals(new BigDecimal(uint256Max), + propertyKey.validValueOrThrow(uint256Max)); + Assert.assertEquals(new BigDecimal(uint256Max), + propertyKey.validValueOrThrow( + new BigInteger(uint256Max))); + Assert.assertEquals(new BigDecimal("0.000000000000000001"), + propertyKey.validValueOrThrow("1E-18")); + // already the expected type: returned as is + BigDecimal exact = new BigDecimal("-1.50"); + Assert.assertSame(exact, propertyKey.validValueOrThrow(exact)); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + propertyKey.validValueOrThrow("1,5"); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + propertyKey.validValueOrThrow(new Date()); + }); + } + + @Test + public void testDefaultValueNormalizedToDecimal() { + // Userdata reloaded from JSON keeps ~default_value as a String; + // defaultValue() must hand back a BigDecimal, exactly + PropertyKey propertyKey = new PropertyKey(null, IdGenerator.of(4), + "balance"); + propertyKey.dataType(DataType.DECIMAL); + propertyKey.userdata(Userdata.DEFAULT_VALUE, "1000000000000000000001"); + + Object value = propertyKey.defaultValue(); + Assert.assertTrue("DEFAULT_VALUE should be a BigDecimal, was " + + (value == null ? "null" : value.getClass()), + value instanceof BigDecimal); + Assert.assertEquals(new BigDecimal("1000000000000000000001"), value); + + // a number literal in the JSON is exact as long as it is integral + propertyKey.userdata(Userdata.DEFAULT_VALUE, 5L); + Assert.assertEquals(new BigDecimal("5"), propertyKey.defaultValue()); + + // list cardinality: every element converted + PropertyKey listKey = new PropertyKey(null, IdGenerator.of(5), + "limits"); + listKey.dataType(DataType.DECIMAL); + listKey.cardinality(Cardinality.LIST); + listKey.userdata(Userdata.DEFAULT_VALUE, Arrays.asList("1", "2.5")); + Object list = listKey.defaultValue(); + Assert.assertTrue(list instanceof List); + Assert.assertEquals(Arrays.asList(new BigDecimal("1"), + new BigDecimal("2.5")), list); + } } From bae56caed8aabce9d83e6be7781723d73c36a7e7 Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Wed, 16 Sep 2026 20:39:55 +0000 Subject: [PATCH 03/11] feat(core): bound DECIMAL precision and scale, check ready-made BigDecimals too - DataType.valueToDecimal() (server and struct): at most 128 significant digits and an absolute scale of 128 (DECIMAL_MAX_PRECISION / DECIMAL_MAX_SCALE); "1E+999999999" is rejected before it is stored instead of costing a billion characters from toPlainString() on every read - PropertyKey.convValue() (server and struct): no short-circuit for a BigDecimal that already has the right type, so a Gremlin literal and the SUM result of a batch update pass the same bounds check - tests: DataTypeTest.testValueToDecimalBounds, struct PropertyKeyTest, PropertyKeyCoreTest --- .../apache/hugegraph/schema/PropertyKey.java | 7 ++- .../hugegraph/type/define/DataType.java | 53 +++++++++++++------ .../hugegraph/core/PropertyKeyCoreTest.java | 13 +++++ .../hugegraph/unit/core/DataTypeTest.java | 36 +++++++++++++ .../hugegraph/struct/schema/PropertyKey.java | 7 ++- .../hugegraph/type/define/DataType.java | 53 +++++++++++++------ .../struct/schema/PropertyKeyTest.java | 11 ++++ 7 files changed, 146 insertions(+), 34 deletions(-) diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java index 266c18388e..18b93aebaf 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java @@ -311,8 +311,11 @@ private V convValue(V value) { if (value == null) { return null; } - if (this.checkValueType(value)) { - // Same as expected type, no conversion required + if (this.checkValueType(value) && !this.dataType().isDecimal()) { + // Same as expected type, no conversion required. A decimal is + // not short-circuited: a ready-made BigDecimal (Gremlin literal, + // SUM result of a batch update) still has to pass the bounds + // check in DataType.valueToDecimal() return value; } diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java index 4ee24e4c80..925f309f3e 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java @@ -166,30 +166,53 @@ public Number valueToNumber(V value) { * @return the BigDecimal, or null if the value is not a Number or String * @throws IllegalArgumentException if the string is not a decimal number */ + /* + * Bounds for a DECIMAL value: at most DECIMAL_MAX_PRECISION significant + * digits and an absolute scale of at most DECIMAL_MAX_SCALE. uint256 + * with 18 fraction digits is 96 digits, so both fit with room to spare, + * while "1E+999999999" (a few bytes on disk, a billion characters from + * toPlainString() on every read) is rejected before it is stored. + */ + public static final int DECIMAL_MAX_PRECISION = 128; + public static final int DECIMAL_MAX_SCALE = 128; + public BigDecimal valueToDecimal(V value) { if (!this.isDecimal()) { return null; } + BigDecimal decimal; if (value instanceof BigDecimal) { - return (BigDecimal) value; - } - if (value instanceof BigInteger) { - return new BigDecimal((BigInteger) value); - } - if (value instanceof Byte || value instanceof Short || - value instanceof Integer || value instanceof Long) { - return BigDecimal.valueOf(((Number) value).longValue()); - } - if (!(value instanceof Number) && !(value instanceof String)) { + decimal = (BigDecimal) value; + } else if (value instanceof BigInteger) { + decimal = new BigDecimal((BigInteger) value); + } else if (value instanceof Byte || value instanceof Short || + value instanceof Integer || value instanceof Long) { + decimal = BigDecimal.valueOf(((Number) value).longValue()); + } else if (!(value instanceof Number) && !(value instanceof String)) { return null; + } else { + String text = value.toString().trim(); + try { + decimal = new BigDecimal(text); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(String.format( + "Can't read '%s' as decimal", value)); + } } - String text = value.toString().trim(); - try { - return new BigDecimal(text); - } catch (NumberFormatException e) { + return checkDecimalBounds(decimal); + } + + public static BigDecimal checkDecimalBounds(BigDecimal decimal) { + int scale = Math.abs(decimal.scale()); + int precision = decimal.precision(); + if (precision > DECIMAL_MAX_PRECISION || scale > DECIMAL_MAX_SCALE) { throw new IllegalArgumentException(String.format( - "Can't read '%s' as decimal", value)); + "Decimal value out of bounds: precision %d, scale %d " + + "(at most %d significant digits and a scale of at most " + + "%d in either direction)", precision, decimal.scale(), + DECIMAL_MAX_PRECISION, DECIMAL_MAX_SCALE)); } + return decimal; } public Date valueToDate(V value) { diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java index 61625ccc94..c509e74fe6 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java @@ -806,6 +806,19 @@ public void testAddPropertyKeyWithDecimalType() { Assert.assertContains("Can't read '1,5' as decimal", e.getMessage()); }); + // bounds hold for strings and for ready-made BigDecimals alike + Assert.assertThrows(IllegalArgumentException.class, () -> { + balance.validValue("1E+999999999"); + }, e -> { + Assert.assertContains("out of bounds", e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + balance.validValue(new BigDecimal("1E+999999999")); + }, e -> { + Assert.assertContains("out of bounds", e.getMessage()); + }); + Assert.assertEquals(new BigDecimal("1E+128"), + balance.validValue(new BigDecimal("1E+128"))); // SUM/MAX/MIN aggregate types are allowed like on any numeric key PropertyKey total = schema.propertyKey("total") diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java index 09e9ca73d0..caf4e8a146 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java @@ -100,6 +100,42 @@ public void testDecimal() { DataType.fromClass(BigDecimal.class)); } + @Test + public void testValueToDecimalBounds() { + // a huge exponent is a few bytes on disk and a billion characters + // from toPlainString() on every read: rejected before it is stored + for (String bad : new String[]{"1E+999999999", "1E-999999999", + "1E+129", "1E-129"}) { + Assert.assertThrows(IllegalArgumentException.class, () -> { + DataType.DECIMAL.valueToDecimal(bad); + }, e -> { + Assert.assertContains("out of bounds", e.getMessage()); + }); + } + // the same check applies to a BigDecimal that arrives ready-made + // (Gremlin literal, SUM result) + Assert.assertThrows(IllegalArgumentException.class, () -> { + DataType.DECIMAL.valueToDecimal(new BigDecimal("1E+999999999")); + }); + // 129 significant digits rejected, 128 accepted + String digits128 = "1".repeat(128); + Assert.assertThrows(IllegalArgumentException.class, () -> { + DataType.DECIMAL.valueToDecimal(digits128 + "1"); + }); + Assert.assertEquals(new BigDecimal(digits128), + DataType.DECIMAL.valueToDecimal(digits128)); + // uint256 max with 18 fraction digits (96 digits) is inside + String uint256Max = "115792089237316195423570985008687907853" + + "269984665640564039457584007913129639935"; + BigDecimal wide = new BigDecimal(uint256Max + ".000000000000000001"); + Assert.assertEquals(wide, DataType.DECIMAL.valueToDecimal(wide)); + // scale boundary in both directions + Assert.assertEquals(new BigDecimal("1E+128"), + DataType.DECIMAL.valueToDecimal("1E+128")); + Assert.assertEquals(new BigDecimal("1E-128"), + DataType.DECIMAL.valueToDecimal("1E-128")); + } + @Test public void testValueToDecimal() { // uint256 max: 78 digits, far beyond long and double diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java index 88efac143b..b1632dc0a5 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/struct/schema/PropertyKey.java @@ -316,8 +316,11 @@ private V convValue(V value) { if (value == null) { return null; } - if (this.checkValueType(value)) { - // Same as expected type, no conversion required + if (this.checkValueType(value) && !this.dataType().isDecimal()) { + // Same as expected type, no conversion required. A decimal is + // not short-circuited: a ready-made BigDecimal (Gremlin literal, + // SUM result of a batch update) still has to pass the bounds + // check in DataType.valueToDecimal() return value; } diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java index 9a9b8a82d4..2a04c46e72 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java @@ -160,30 +160,53 @@ public Number valueToNumber(V value) { return number; } + /* + * Bounds for a DECIMAL value: at most DECIMAL_MAX_PRECISION significant + * digits and an absolute scale of at most DECIMAL_MAX_SCALE. uint256 + * with 18 fraction digits is 96 digits, so both fit with room to spare, + * while "1E+999999999" (a few bytes on disk, a billion characters from + * toPlainString() on every read) is rejected before it is stored. + */ + public static final int DECIMAL_MAX_PRECISION = 128; + public static final int DECIMAL_MAX_SCALE = 128; + public BigDecimal valueToDecimal(V value) { if (!this.isDecimal()) { return null; } + BigDecimal decimal; if (value instanceof BigDecimal) { - return (BigDecimal) value; - } - if (value instanceof BigInteger) { - return new BigDecimal((BigInteger) value); - } - if (value instanceof Byte || value instanceof Short || - value instanceof Integer || value instanceof Long) { - return BigDecimal.valueOf(((Number) value).longValue()); - } - if (!(value instanceof Number) && !(value instanceof String)) { + decimal = (BigDecimal) value; + } else if (value instanceof BigInteger) { + decimal = new BigDecimal((BigInteger) value); + } else if (value instanceof Byte || value instanceof Short || + value instanceof Integer || value instanceof Long) { + decimal = BigDecimal.valueOf(((Number) value).longValue()); + } else if (!(value instanceof Number) && !(value instanceof String)) { return null; + } else { + String text = value.toString().trim(); + try { + decimal = new BigDecimal(text); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(String.format( + "Can't read '%s' as decimal", value)); + } } - String text = value.toString().trim(); - try { - return new BigDecimal(text); - } catch (NumberFormatException e) { + return checkDecimalBounds(decimal); + } + + public static BigDecimal checkDecimalBounds(BigDecimal decimal) { + int scale = Math.abs(decimal.scale()); + int precision = decimal.precision(); + if (precision > DECIMAL_MAX_PRECISION || scale > DECIMAL_MAX_SCALE) { throw new IllegalArgumentException(String.format( - "Can't read '%s' as decimal", value)); + "Decimal value out of bounds: precision %d, scale %d " + + "(at most %d significant digits and a scale of at most " + + "%d in either direction)", precision, decimal.scale(), + DECIMAL_MAX_PRECISION, DECIMAL_MAX_SCALE)); } + return decimal; } public Date valueToDate(V value) { diff --git a/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java b/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java index cbb1a2bcd8..426c7e01e1 100644 --- a/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java +++ b/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java @@ -132,6 +132,17 @@ public void testDecimalValueConversion() { Assert.assertThrows(IllegalArgumentException.class, () -> { propertyKey.validValueOrThrow(new Date()); }); + // bounds: a huge exponent must not reach the store + for (String bad : new String[]{"1E+999999999", "1E-999999999"}) { + Assert.assertThrows(IllegalArgumentException.class, () -> { + propertyKey.validValueOrThrow(bad); + }); + } + Assert.assertThrows(IllegalArgumentException.class, () -> { + propertyKey.validValueOrThrow(new BigDecimal("1E+999999999")); + }); + Assert.assertEquals(new BigDecimal("1E+128"), + propertyKey.validValueOrThrow("1E+128")); } @Test From 834ef8940fc0d7c6ae7dbce9e850c13af91fe97a Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Thu, 17 Sep 2026 04:52:04 +0000 Subject: [PATCH 04/11] feat(core): reject a DECIMAL vertex primary key A primary key becomes part of the vertex id through LongEncoding and NumericUtil.numberToSortableLong(), which goes through a double for fractions (1.000000000000000001 and ...002 collapse into one id) and overflows a long on uint256. VertexLabelBuilder.checkPrimaryKeys() now rejects a decimal property as a primary key, single or composite, next to the existing sort-key and index guards; core test added. --- .../schema/builder/VertexLabelBuilder.java | 7 ++++ .../hugegraph/core/VertexLabelCoreTest.java | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/VertexLabelBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/VertexLabelBuilder.java index 4962646209..5b3e25f55b 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/VertexLabelBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/VertexLabelBuilder.java @@ -512,6 +512,13 @@ private void checkPrimaryKeys() { "The primary key '%s' of vertex label '%s' " + "must be contained in properties: %s", key, this.name, this.properties); + // A primary key becomes part of the vertex id through + // LongEncoding/NumericUtil, which is lossy for a decimal + // (fractions collapse into a double, uint256 overflows a long) + PropertyKey propertyKey = this.graph().propertyKey(key); + E.checkArgument(!propertyKey.dataType().isDecimal(), + "The primary key '%s' of vertex label '%s' " + + "can't be a decimal property", key, this.name); } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/VertexLabelCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/VertexLabelCoreTest.java index a43731f235..54045df915 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/VertexLabelCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/VertexLabelCoreTest.java @@ -335,6 +335,42 @@ public void testAddVertexWithPrimaryKeyIdStrategyButNotPassedPk() { }); } + @Test + public void testAddVertexLabelWithDecimalPrimaryKey() { + super.initPropertyKeys(); + SchemaManager schema = graph().schema(); + schema.propertyKey("balance").asDecimal().create(); + + // a decimal cannot be part of the vertex id: LongEncoding/NumericUtil + // collapse fractions into a double and overflow a long on uint256 + Assert.assertThrows(IllegalArgumentException.class, () -> { + schema.vertexLabel("account") + .properties("balance", "name") + .primaryKeys("balance") + .create(); + }, e -> { + Assert.assertContains("can't be a decimal property", + e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + schema.vertexLabel("account") + .properties("name", "balance") + .primaryKeys("name", "balance") + .create(); + }, e -> { + Assert.assertContains("can't be a decimal property", + e.getMessage()); + }); + Assert.assertFalse(graph().existsVertexLabel("account")); + + // as a plain property next to a text primary key it is fine + VertexLabel account = schema.vertexLabel("account") + .properties("name", "balance") + .primaryKeys("name") + .create(); + Assert.assertEquals(1, account.primaryKeys().size()); + } + @Test public void testAddVertexLabelWith2PrimaryKey() { super.initPropertyKeys(); From 0146849bcfbe2d9430789dfa34bab681bba7d010 Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Mon, 21 Sep 2026 21:39:32 +0000 Subject: [PATCH 05/11] feat(api): read JSON fraction literals as BigDecimal so DECIMAL values stay exact Jersey parsed request bodies with a default ObjectMapper, so a JSON fraction such as 12345678901234567890.10 reached a DECIMAL property key as a double (17 digits) and decimal fractions had to be sent as strings. An ObjectMapperResolver now enables USE_BIG_DECIMAL_FOR_FLOATS for REST bodies: DECIMAL keys receive every digit, numeric keys are narrowed by DataType.valueToNumber as before (any Number is accepted), and integer keys still reject a fraction. JsonUtil.castNumber converts a BigDecimal to double instead of asserting the JSON type. Tests: VertexApiTest covers a 39-digit literal on a DECIMAL and a DOUBLE key, an exponent literal, an INT key rejecting a fraction, and the batch SUM increment sent as a number literal. --- .../hugegraph/server/ApplicationConfig.java | 2 + .../server/ObjectMapperResolver.java | 48 +++++++++++ .../org/apache/hugegraph/util/JsonUtil.java | 3 +- .../apache/hugegraph/api/VertexApiTest.java | 83 ++++++++++++++++++- 4 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ObjectMapperResolver.java diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ApplicationConfig.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ApplicationConfig.java index 1930a1146c..9fdbf39f64 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ApplicationConfig.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ApplicationConfig.java @@ -73,6 +73,8 @@ public ApplicationConfig(HugeConfig conf, EventHub hub) { // Register Jackson to support json register(org.glassfish.jersey.jackson.JacksonFeature.class); + // Read JSON fraction literals as BigDecimal (exact DECIMAL values) + register(ObjectMapperResolver.class); // Register to use the jsr250 annotations @RolesAllowed register(RolesAllowedDynamicFeature.class); diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ObjectMapperResolver.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ObjectMapperResolver.java new file mode 100644 index 0000000000..684bc1644c --- /dev/null +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ObjectMapperResolver.java @@ -0,0 +1,48 @@ +/* + * 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.server; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.ws.rs.ext.Provider; + +/** + * The Jackson mapper Jersey uses for REST request bodies. + * + * JSON fraction literals are read as BigDecimal instead of double, so a + * value such as {@code 12345678901234567890.10} reaches a DECIMAL property + * key exactly. Numeric keys are unaffected: DataType.valueToNumber accepts + * any Number and narrows it to the key's type as before. + */ +@Provider +public class ObjectMapperResolver implements ContextResolver { + + private final ObjectMapper mapper; + + public ObjectMapperResolver() { + this.mapper = new ObjectMapper(); + this.mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + } + + @Override + public ObjectMapper getContext(Class type) { + return this.mapper; + } +} diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java index fd00816f66..140983180a 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java @@ -113,7 +113,8 @@ public static Object castNumber(Object object, Class clazz) { } else if (clazz == Float.class) { object = number.floatValue(); } else if (clazz == Double.class) { - assert object instanceof Double : object; + // A JSON fraction may arrive as BigDecimal, see ObjectMapperResolver + object = number.doubleValue(); } else { assert clazz == Date.class : clazz; } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java index 88df1293cd..4f1912ac05 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java @@ -102,6 +102,80 @@ public void testDelete() throws IOException { assertResponseStatus(204, r); } + @Test + public void testDecimalJsonNumberLiteralIsExact() throws IOException { + createAndAssert(URL_PREFIX + "/schema/propertykeys", + "{" + + "\"name\": \"amount\"," + + "\"data_type\": \"DECIMAL\"," + + "\"cardinality\": \"SINGLE\"," + + "\"check_exist\": false," + + "\"properties\":[]" + + "}", 202); + createAndAssert(URL_PREFIX + "/schema/propertykeys", + "{" + + "\"name\": \"weight\"," + + "\"data_type\": \"DOUBLE\"," + + "\"cardinality\": \"SINGLE\"," + + "\"check_exist\": false," + + "\"properties\":[]" + + "}", 202); + createAndAssert(URL_PREFIX + "/schema/vertexlabels", + "{" + + "\"primary_keys\":[\"name\"]," + + "\"id_strategy\": \"PRIMARY_KEY\"," + + "\"name\": \"transfer\"," + + "\"properties\":[\"name\", \"amount\", \"weight\"]," + + "\"check_exist\": false," + + "\"nullable_keys\":[\"amount\", \"weight\"]" + + "}"); + + // 39 significant digits as a JSON number literal: a double parser + // would keep 17 of them; the value is stored and echoed exactly + String literal = "12345678901234567890.123456789012345678"; + String vertex = "{" + + "\"label\":\"transfer\"," + + "\"properties\":{" + + "\"name\":\"t1\"," + + "\"amount\":" + literal + "," + + "\"weight\":" + literal + "}" + + "}"; + Response r = client().post(PATH, vertex); + String content = assertResponseStatus(201, r); + Assert.assertContains("\"amount\":\"" + literal + "\"", content); + // the DOUBLE key narrows the same literal to a double, as before + Assert.assertContains("\"weight\":1.2345678901234567E19", content); + + r = client().get(PATH, String.format("\"%s\"", parseId(content))); + content = assertResponseStatus(200, r); + Assert.assertContains("\"amount\":\"" + literal + "\"", content); + + // exponent literals are accepted and stored in plain form + vertex = "{" + + "\"label\":\"transfer\"," + + "\"properties\":{" + + "\"name\":\"t2\"," + + "\"amount\":1E-18," + + "\"weight\":2.5}" + + "}"; + r = client().post(PATH, vertex); + content = assertResponseStatus(201, r); + Assert.assertContains("\"amount\":\"0.000000000000000001\"", content); + Assert.assertContains("\"weight\":2.5", content); + + // integer keys still reject a fraction, with the usual message + vertex = "{" + + "\"label\":\"person\"," + + "\"properties\":{" + + "\"name\":\"t3\"," + + "\"age\":29.5," + + "\"city\":\"Beijing\"}" + + "}"; + r = client().post(PATH, vertex); + content = assertResponseStatus(400, r); + Assert.assertContains("Invalid property value", content); + } + @Test public void testBatchUpdateDecimalWithSumStrategy() throws IOException { // schema: a decimal balance on an account keyed by name @@ -155,9 +229,10 @@ public void testBatchUpdateDecimalWithSumStrategy() throws IOException { content = assertResponseStatus(200, r); Assert.assertContains("\"balance\":\"" + max + "\"", content); - // a fraction is sent as a string (a JSON fraction literal would be a - // double to the parser); two entries for the same vertex in one - // request are combined first, then added to the stored value + // a fraction as a string and as a JSON number literal (read as + // BigDecimal, see ObjectMapperResolver); two entries for the same + // vertex in one request are combined first, then added to the + // stored value batch = "{" + "\"vertices\":[{" + "\"label\":\"account\"," + @@ -168,7 +243,7 @@ public void testBatchUpdateDecimalWithSumStrategy() throws IOException { "\"label\":\"account\"," + "\"properties\":{" + "\"name\":\"alice\"," + - "\"balance\":\"0.000000000000000001\"}" + + "\"balance\":0.000000000000000001}" + "}]," + "\"update_strategies\":{\"balance\":\"SUM\"}," + "\"create_if_not_exist\":true" + From 47aa818fc02a09b00257ea046a18b6f85afceaed Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Wed, 30 Sep 2026 07:05:29 +0000 Subject: [PATCH 06/11] fix(core): exact DECIMAL values only where they belong: property bodies, store filters, GraphSON output Review round 3 of #3209: - The global Jersey ObjectMapper switch (0146849b) is gone. Only the "properties" object of a vertex/edge body is read with exact fractions, through PropertiesDeserializer on BatchAPI.JsonElement; job parameters, schema userdata and every other body keep Jackson's default number types, so a fractional algorithm parameter or a DOUBLE ~default_value round-trips as before. - A DECIMAL condition now travels to the store typed: QueryAdapter (core and struct copies) tags BigDecimal and BigInteger values with their class, as it does for primitive wrappers and Date, so the Store's ConditionQuery.fromBytes reads the exact value instead of a double; the struct ConditionQuery compares decimals exactly like the core copy. - The GraphSON BigDecimal serializer uses the plain form only while the scale is within the DECIMAL bound and the scientific form beyond it, so an unbounded Gremlin result cannot expand an exponent into a billion characters. - checkDecimalBounds compares the scale directly with the bound in both copies (Math.abs(Integer.MIN_VALUE) overflowed); the Javadoc of valueToDecimal sits above the method again. Tests: PropertiesDeserializerTest (exact fractions in properties only, null and non-object), StoreSerializerTest (BigDecimal eq/gte/in conditions through bytes()/fromBytes()), DataTypeTest and the struct PropertyKeyTest (extreme scales), JsonUtilTest (exponent form beyond the bound, doubles stay numbers), VertexApiTest (exact filter by a 39-digit value and a near miss, DOUBLE key with a fractional default). --- .../apache/hugegraph/api/graph/BatchAPI.java | 2 + .../api/graph/PropertiesDeserializer.java | 102 ++++++++++++++++++ .../hugegraph/server/ApplicationConfig.java | 2 - .../server/ObjectMapperResolver.java | 48 --------- .../query/serializer/QueryAdapter.java | 9 +- .../hugegraph/io/HugeGraphSONModule.java | 19 +++- .../hugegraph/type/define/DataType.java | 26 ++--- .../org/apache/hugegraph/util/JsonUtil.java | 2 +- .../apache/hugegraph/api/VertexApiTest.java | 60 +++++++++++ .../apache/hugegraph/unit/UnitTestSuite.java | 2 + .../api/graph/PropertiesDeserializerTest.java | 91 ++++++++++++++++ .../hugegraph/unit/core/DataTypeTest.java | 10 ++ .../unit/serializer/StoreSerializerTest.java | 36 +++++++ .../hugegraph/unit/util/JsonUtilTest.java | 16 +++ .../hugegraph/query/ConditionQuery.java | 10 ++ .../query/serializer/QueryAdapter.java | 9 +- .../hugegraph/type/define/DataType.java | 6 +- .../struct/schema/PropertyKeyTest.java | 6 ++ 18 files changed, 388 insertions(+), 68 deletions(-) create mode 100644 hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java delete mode 100644 hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ObjectMapperResolver.java create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/BatchAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/BatchAPI.java index 0e58d08325..a3b8a19bbe 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/BatchAPI.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/BatchAPI.java @@ -40,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; @@ -93,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 properties; @Schema(description = "The type of element (vertex or edge)", hidden = true) @JsonProperty("type") diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java new file mode 100644 index 0000000000..4c3959d454 --- /dev/null +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java @@ -0,0 +1,102 @@ +/* + * 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 + * 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> { + + @Override + public Map 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 readObject(JsonParser parser) + throws IOException { + Map 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 readArray(JsonParser parser) + throws IOException { + List array = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + array.add(readValue(parser)); + } + return array; + } + + private 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(); + 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); + } + } +} diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ApplicationConfig.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ApplicationConfig.java index 9fdbf39f64..1930a1146c 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ApplicationConfig.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ApplicationConfig.java @@ -73,8 +73,6 @@ public ApplicationConfig(HugeConfig conf, EventHub hub) { // Register Jackson to support json register(org.glassfish.jersey.jackson.JacksonFeature.class); - // Read JSON fraction literals as BigDecimal (exact DECIMAL values) - register(ObjectMapperResolver.class); // Register to use the jsr250 annotations @RolesAllowed register(RolesAllowedDynamicFeature.class); diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ObjectMapperResolver.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ObjectMapperResolver.java deleted file mode 100644 index 684bc1644c..0000000000 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/server/ObjectMapperResolver.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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.server; - -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; - -import jakarta.ws.rs.ext.ContextResolver; -import jakarta.ws.rs.ext.Provider; - -/** - * The Jackson mapper Jersey uses for REST request bodies. - * - * JSON fraction literals are read as BigDecimal instead of double, so a - * value such as {@code 12345678901234567890.10} reaches a DECIMAL property - * key exactly. Numeric keys are unaffected: DataType.valueToNumber accepts - * any Number and narrows it to the key's type as before. - */ -@Provider -public class ObjectMapperResolver implements ContextResolver { - - private final ObjectMapper mapper; - - public ObjectMapperResolver() { - this.mapper = new ObjectMapper(); - this.mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); - } - - @Override - public ObjectMapper getContext(Class type) { - return this.mapper; - } -} diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/serializer/QueryAdapter.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/serializer/QueryAdapter.java index 47b7c76a00..de10ce1ba4 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/serializer/QueryAdapter.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/serializer/QueryAdapter.java @@ -18,6 +18,8 @@ package org.apache.hugegraph.backend.query.serializer; import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -48,8 +50,13 @@ public class QueryAdapter extends AbstractSerializerAdapter { .build(); static boolean isPrimitive(Class clz) { + // Values whose class must travel with them: Gson reads an untyped + // JSON number back as a double, which would round a BigDecimal + if (clz == Date.class || clz == BigDecimal.class || clz == BigInteger.class) { + return true; + } try { - return (clz == Date.class) || ((Class) clz.getField("TYPE").get(null)).isPrimitive(); + return ((Class) clz.getField("TYPE").get(null)).isPrimitive(); } catch (Exception e) { return false; } diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java index ef36d72ed3..a2cc8b387d 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java @@ -52,6 +52,7 @@ import org.apache.hugegraph.structure.HugeElement; import org.apache.hugegraph.structure.HugeProperty; import org.apache.hugegraph.structure.HugeVertex; +import org.apache.hugegraph.type.define.DataType; import org.apache.hugegraph.type.define.HugeKeys; import org.apache.hugegraph.util.Blob; import org.apache.hugegraph.util.Log; @@ -971,7 +972,23 @@ public BigDecimalSerializer() { @Override public void serialize(BigDecimal decimal, JsonGenerator jsonGenerator, SerializerProvider provider) throws IOException { - jsonGenerator.writeString(decimal.toPlainString()); + jsonGenerator.writeString(exactString(decimal)); + } + + /** + * The plain form ("1000", "0.000000000000000001") while the scale + * is within the DECIMAL bound, so a stored value always reads as + * digits; beyond it the scientific form ("1E+999999999"), which is + * just as exact but does not expand the exponent into characters. + * A generic Gremlin result is not bound by the property check. + */ + static String exactString(BigDecimal decimal) { + int scale = decimal.scale(); + if (scale >= -DataType.DECIMAL_MAX_SCALE && + scale <= DataType.DECIMAL_MAX_SCALE) { + return decimal.toPlainString(); + } + return decimal.toString(); } @Override diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java index 925f309f3e..9a1bf987c0 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/type/define/DataType.java @@ -156,16 +156,6 @@ public Number valueToNumber(V value) { return number; } - /** - * Convert a value to BigDecimal: BigDecimal as is, any other Number and a - * decimal string through their exact decimal representation. Float and - * Double go through Number.toString(), i.e. the shortest string that - * round-trips the binary value, so a client that already holds a lossy - * double gets that double, exactly. - * - * @return the BigDecimal, or null if the value is not a Number or String - * @throws IllegalArgumentException if the string is not a decimal number - */ /* * Bounds for a DECIMAL value: at most DECIMAL_MAX_PRECISION significant * digits and an absolute scale of at most DECIMAL_MAX_SCALE. uint256 @@ -176,6 +166,16 @@ public Number valueToNumber(V value) { public static final int DECIMAL_MAX_PRECISION = 128; public static final int DECIMAL_MAX_SCALE = 128; + /** + * Convert a value to BigDecimal: BigDecimal as is, any other Number and a + * decimal string through their exact decimal representation. Float and + * Double go through Number.toString(), i.e. the shortest string that + * round-trips the binary value, so a client that already holds a lossy + * double gets that double, exactly. + * + * @return the BigDecimal, or null if the value is not a Number or String + * @throws IllegalArgumentException if the string is not a decimal number + */ public BigDecimal valueToDecimal(V value) { if (!this.isDecimal()) { return null; @@ -203,9 +203,11 @@ public BigDecimal valueToDecimal(V value) { } public static BigDecimal checkDecimalBounds(BigDecimal decimal) { - int scale = Math.abs(decimal.scale()); + int scale = decimal.scale(); int precision = decimal.precision(); - if (precision > DECIMAL_MAX_PRECISION || scale > DECIMAL_MAX_SCALE) { + // Compare the scale directly: Math.abs(Integer.MIN_VALUE) overflows + if (precision > DECIMAL_MAX_PRECISION || + scale < -DECIMAL_MAX_SCALE || scale > DECIMAL_MAX_SCALE) { throw new IllegalArgumentException(String.format( "Decimal value out of bounds: precision %d, scale %d " + "(at most %d significant digits and a scale of at most " + diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java index 140983180a..b0440fe203 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java @@ -113,7 +113,7 @@ public static Object castNumber(Object object, Class clazz) { } else if (clazz == Float.class) { object = number.floatValue(); } else if (clazz == Double.class) { - // A JSON fraction may arrive as BigDecimal, see ObjectMapperResolver + // A property value may arrive as BigDecimal (exact JSON fraction) object = number.doubleValue(); } else { assert clazz == Date.class : clazz; diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java index 4f1912ac05..28bfdb3639 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java @@ -18,6 +18,8 @@ package org.apache.hugegraph.api; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import org.apache.hugegraph.testutil.Assert; import org.junit.Before; @@ -163,6 +165,64 @@ public void testDecimalJsonNumberLiteralIsExact() throws IOException { Assert.assertContains("\"amount\":\"0.000000000000000001\"", content); Assert.assertContains("\"weight\":2.5", content); + // Filtering by the decimal value is exact on every backend. A vertex + // filter without an index is refused, so the case that reaches the + // store is an edge query by vertex + label + property: on HStore + // the condition is pushed down and must arrive as a BigDecimal + createAndAssert(URL_PREFIX + "/schema/edgelabels", + "{" + + "\"name\": \"pay\"," + + "\"source_label\": \"person\"," + + "\"target_label\": \"person\"," + + "\"frequency\": \"SINGLE\"," + + "\"properties\":[\"amount\"]," + + "\"nullable_keys\":[\"amount\"]," + + "\"check_exist\": false" + + "}"); + String payer = parseId(assertResponseStatus(201, client().post(PATH, + "{\"label\":\"person\",\"properties\":{\"name\":\"payer\"," + + "\"age\":30,\"city\":\"Beijing\"}}"))); + String payee = parseId(assertResponseStatus(201, client().post(PATH, + "{\"label\":\"person\",\"properties\":{\"name\":\"payee\"," + + "\"age\":31,\"city\":\"Beijing\"}}"))); + String edge = "{\"label\":\"pay\",\"outVLabel\":\"person\"," + + "\"inVLabel\":\"person\",\"outV\":\"" + payer + "\"," + + "\"inV\":\"" + payee + "\"," + + "\"properties\":{\"amount\":" + literal + "}}"; + content = assertResponseStatus(201, client().post( + URL_PREFIX + "/graph/edges/", edge)); + Assert.assertContains("\"amount\":\"" + literal + "\"", content); + + String edges = URL_PREFIX + "/graph/edges/"; + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pay", + "properties", URLEncoder.encode("{\"amount\":\"" + literal + "\"}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertContains("\"amount\":\"" + literal + "\"", content); + String near = literal.substring(0, literal.length() - 1) + "9"; + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pay", + "properties", URLEncoder.encode("{\"amount\":\"" + near + "\"}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertEquals("{\"edges\":[]}", content); + + // a fraction elsewhere in a body keeps its usual type: a DOUBLE key + // with a fractional default value round-trips as a JSON number + createAndAssert(URL_PREFIX + "/schema/propertykeys", + "{" + + "\"name\": \"ratio\"," + + "\"data_type\": \"DOUBLE\"," + + "\"cardinality\": \"SINGLE\"," + + "\"check_exist\": false," + + "\"user_data\": {\"~default_value\": 1.5}," + + "\"properties\":[]" + + "}", 202); + r = client().get(URL_PREFIX + "/schema/propertykeys/", "ratio"); + content = assertResponseStatus(200, r); + Assert.assertContains("\"~default_value\":1.5", content); + // integer keys still reject a fraction, with the usual message vertex = "{" + "\"label\":\"person\"," + diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index 0b002852a4..5ec64d0b25 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -31,6 +31,7 @@ import org.apache.hugegraph.unit.api.auth.LoginAPITest; import org.apache.hugegraph.unit.api.filter.AccessLogFilterTest; import org.apache.hugegraph.unit.api.filter.LoadDetectFilterTest; +import org.apache.hugegraph.unit.api.graph.PropertiesDeserializerTest; import org.apache.hugegraph.unit.api.filter.PathFilterTest; import org.apache.hugegraph.unit.api.gremlin.GremlinQueryAPITest; import org.apache.hugegraph.unit.api.space.GraphSpaceAPITest; @@ -108,6 +109,7 @@ /* api filter */ AccessLogFilterTest.class, LoadDetectFilterTest.class, + PropertiesDeserializerTest.class, LoginAPITest.class, PathFilterTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java new file mode 100644 index 0000000000..26b719daaf --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java @@ -0,0 +1,91 @@ +/* + * 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.unit.api.graph; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import org.apache.hugegraph.api.graph.PropertiesDeserializer; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +public class PropertiesDeserializerTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public static class Body { + + @JsonProperty("label") + public String label; + @JsonProperty("properties") + @JsonDeserialize(using = PropertiesDeserializer.class) + public Map properties; + @JsonProperty("options") + public Map options; + } + + @Test + public void testFractionsAreExactInPropertiesOnly() throws Exception { + Body body = MAPPER.readValue( + "{\"label\":\"account\"," + + "\"properties\":{\"amount\":12345678901234567890.123456789012345678," + + "\"rate\":1.10,\"count\":7,\"big\":123456789012345678901234567890," + + "\"name\":\"a\",\"ok\":true,\"none\":null," + + "\"tags\":[1.5,\"x\",[2.25]],\"nested\":{\"w\":0.1}}," + + "\"options\":{\"alpha\":0.85}}", Body.class); + + Map props = body.properties; + Assert.assertEquals(new BigDecimal("12345678901234567890.123456789012345678"), + props.get("amount")); + Assert.assertEquals(new BigDecimal("1.10"), props.get("rate")); + Assert.assertEquals(7, props.get("count")); + Assert.assertEquals(new java.math.BigInteger("123456789012345678901234567890"), + props.get("big")); + Assert.assertEquals("a", props.get("name")); + Assert.assertEquals(Boolean.TRUE, props.get("ok")); + Assert.assertTrue(props.containsKey("none")); + Assert.assertNull(props.get("none")); + List tags = (List) props.get("tags"); + Assert.assertEquals(new BigDecimal("1.5"), tags.get(0)); + Assert.assertEquals("x", tags.get(1)); + Assert.assertEquals(new BigDecimal("2.25"), ((List) tags.get(2)).get(0)); + Assert.assertEquals(new BigDecimal("0.1"), + ((Map) props.get("nested")).get("w")); + // key order is kept + Assert.assertEquals("amount", props.keySet().iterator().next()); + + // everything outside "properties" keeps Jackson's default types + Assert.assertEquals(0.85d, body.options.get("alpha")); + } + + @Test + public void testNullAndNonObject() throws Exception { + Body body = MAPPER.readValue("{\"label\":\"x\",\"properties\":null}", + Body.class); + Assert.assertNull(body.properties); + Assert.assertThrows(com.fasterxml.jackson.databind.JsonMappingException.class, + () -> { + MAPPER.readValue("{\"label\":\"x\",\"properties\":[1]}", Body.class); + }); + } +} diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java index caf4e8a146..5f7c4398fe 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java @@ -104,6 +104,16 @@ public void testDecimal() { public void testValueToDecimalBounds() { // a huge exponent is a few bytes on disk and a billion characters // from toPlainString() on every read: rejected before it is stored + // the extreme scales, where Math.abs(Integer.MIN_VALUE) would overflow + for (int scale : new int[]{Integer.MIN_VALUE, Integer.MIN_VALUE + 1, + Integer.MAX_VALUE}) { + Assert.assertThrows(IllegalArgumentException.class, () -> { + DataType.DECIMAL.valueToDecimal( + new BigDecimal(BigInteger.ONE, scale)); + }, e -> { + Assert.assertContains("out of bounds", e.getMessage()); + }); + } for (String bad : new String[]{"1E+999999999", "1E-999999999", "1E+129", "1E-129"}) { Assert.assertThrows(IllegalArgumentException.class, () -> { diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/StoreSerializerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/StoreSerializerTest.java index 321f018de2..82682279f8 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/StoreSerializerTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/StoreSerializerTest.java @@ -17,8 +17,13 @@ package org.apache.hugegraph.unit.serializer; +import java.math.BigDecimal; import java.util.Iterator; +import org.apache.hugegraph.backend.id.Id; +import org.apache.hugegraph.backend.id.IdGenerator; +import org.apache.hugegraph.backend.query.Condition; +import org.apache.hugegraph.backend.query.ConditionQuery; import org.apache.hugegraph.backend.serializer.BinaryBackendEntry; import org.apache.hugegraph.backend.serializer.BytesBuffer; import org.apache.hugegraph.backend.store.BackendAction; @@ -33,6 +38,8 @@ import org.apache.hugegraph.type.define.Action; import org.junit.Test; +import com.google.common.collect.ImmutableList; + public class StoreSerializerTest { @Test @@ -63,6 +70,35 @@ public void testSerializeBackendMutation() { } } + @Test + public void testConditionQueryBytesKeepBigDecimal() { + // A DECIMAL filter travels to the store as a BigDecimal, not as an + // untyped JSON number that Gson would read back as a double + Id key = IdGenerator.of(7L); + BigDecimal exact = new BigDecimal("12345678901234567890.123456789012345678"); + ConditionQuery query = new ConditionQuery(HugeType.VERTEX); + query.query(Condition.eq(key, exact)); + query.query(Condition.gte(IdGenerator.of(8L), new BigDecimal("0.000000000000000001"))); + query.query(Condition.in(IdGenerator.of(9L), + ImmutableList.of(new BigDecimal("1.10"), + new BigDecimal("2.20")))); + + ConditionQuery copy = ConditionQuery.fromBytes(query.bytes()); + Object value = copy.userpropValue(key); + Assert.assertEquals(BigDecimal.class, value.getClass()); + Assert.assertEquals(exact, value); + Object low = copy.userpropValue(IdGenerator.of(8L)); + Assert.assertEquals(new BigDecimal("0.000000000000000001"), low); + Object list = copy.userpropValue(IdGenerator.of(9L)); + Assert.assertEquals(ImmutableList.of(new BigDecimal("1.10"), + new BigDecimal("2.20")), list); + // a plain double condition is unchanged + query = new ConditionQuery(HugeType.VERTEX); + query.query(Condition.eq(key, 1.5d)); + Assert.assertEquals(1.5d, ConditionQuery.fromBytes(query.bytes()) + .userpropValue(key)); + } + @Test public void testSerializeStoreCommand() { BinaryBackendEntry entry = new BinaryBackendEntry(HugeType.VERTEX, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java index 596b364691..024f00e750 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java @@ -18,6 +18,7 @@ package org.apache.hugegraph.unit.util; import java.math.BigDecimal; +import java.util.Map; import java.util.Arrays; import java.util.Collections; import java.util.Date; @@ -332,6 +333,21 @@ public void testSerializeBigDecimal() { JsonUtil.toJson(new BigDecimal("-1.50"))); Assert.assertEquals("{\"balance\":\"1000000000000000000000\"}", JsonUtil.toJson(ImmutableMap.of("balance", decimal))); + // beyond the DECIMAL scale bound (a generic Gremlin result, not a + // property) the scientific form: exact, and no exponent expansion + Assert.assertEquals("\"1E+999999999\"", + JsonUtil.toJson(new BigDecimal("1E+999999999"))); + Assert.assertEquals("\"1E-129\"", + JsonUtil.toJson(new BigDecimal("1E-129"))); + String zeros128 = new String(new char[128]).replace("\0", "0"); + Assert.assertEquals("\"1" + zeros128 + "\"", + JsonUtil.toJson(new BigDecimal("1E+128"))); + // a double stays a JSON number: job parameters and schema userdata + // are not touched by the decimal handling + Assert.assertEquals("{\"alpha\":0.85}", + JsonUtil.toJson(ImmutableMap.of("alpha", 0.85d))); + Assert.assertEquals(0.85d, JsonUtil.fromJson("{\"alpha\":0.85}", + Map.class).get("alpha")); // both a string and a number literal are accepted on the way in Assert.assertEquals(new BigDecimal("1.5"), diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/query/ConditionQuery.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/query/ConditionQuery.java index 553fec9b8c..b56e9dd8ce 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/query/ConditionQuery.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/query/ConditionQuery.java @@ -171,6 +171,11 @@ private static boolean numberEquals(Object number1, Object number2) { // Otherwise convert to BigDecimal to make two numbers comparable Number n1 = NumericUtil.convertToNumber(number1); Number n2 = NumericUtil.convertToNumber(number2); + if (n1 instanceof BigDecimal || n2 instanceof BigDecimal) { + // Exact: a decimal must not be squeezed through a double + return new BigDecimal(n1.toString()) + .compareTo(new BigDecimal(n2.toString())) == 0; + } BigDecimal b1 = BigDecimal.valueOf(n1.doubleValue()); BigDecimal b2 = BigDecimal.valueOf(n2.doubleValue()); return b1.compareTo(b2) == 0; @@ -1036,6 +1041,11 @@ private static boolean numberEquals(Object number1, Object number2) { // Otherwise convert to BigDecimal to make two numbers comparable Number n1 = NumericUtil.convertToNumber(number1); Number n2 = NumericUtil.convertToNumber(number2); + if (n1 instanceof BigDecimal || n2 instanceof BigDecimal) { + // Exact: a decimal must not be squeezed through a double + return new BigDecimal(n1.toString()) + .compareTo(new BigDecimal(n2.toString())) == 0; + } BigDecimal b1 = BigDecimal.valueOf(n1.doubleValue()); BigDecimal b2 = BigDecimal.valueOf(n2.doubleValue()); return b1.compareTo(b2) == 0; diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/query/serializer/QueryAdapter.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/query/serializer/QueryAdapter.java index e9975f57cd..bc6d043c9d 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/query/serializer/QueryAdapter.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/query/serializer/QueryAdapter.java @@ -18,6 +18,8 @@ package org.apache.hugegraph.query.serializer; import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -48,8 +50,13 @@ public class QueryAdapter extends AbstractSerializerAdapter { .build(); static boolean isPrimitive(Class clz) { + // Values whose class must travel with them: Gson reads an untyped + // JSON number back as a double, which would round a BigDecimal + if (clz == Date.class || clz == BigDecimal.class || clz == BigInteger.class) { + return true; + } try { - return (clz == Date.class) || ((Class) clz.getField("TYPE").get(null)).isPrimitive(); + return ((Class) clz.getField("TYPE").get(null)).isPrimitive(); } catch (Exception e) { return false; } diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java index 2a04c46e72..6f2f4bd547 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/type/define/DataType.java @@ -197,9 +197,11 @@ public BigDecimal valueToDecimal(V value) { } public static BigDecimal checkDecimalBounds(BigDecimal decimal) { - int scale = Math.abs(decimal.scale()); + int scale = decimal.scale(); int precision = decimal.precision(); - if (precision > DECIMAL_MAX_PRECISION || scale > DECIMAL_MAX_SCALE) { + // Compare the scale directly: Math.abs(Integer.MIN_VALUE) overflows + if (precision > DECIMAL_MAX_PRECISION || + scale < -DECIMAL_MAX_SCALE || scale > DECIMAL_MAX_SCALE) { throw new IllegalArgumentException(String.format( "Decimal value out of bounds: precision %d, scale %d " + "(at most %d significant digits and a scale of at most " + diff --git a/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java b/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java index 426c7e01e1..4c5f1d2cc8 100644 --- a/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java +++ b/hugegraph-struct/src/test/java/org/apache/hugegraph/struct/schema/PropertyKeyTest.java @@ -138,6 +138,12 @@ public void testDecimalValueConversion() { propertyKey.validValueOrThrow(bad); }); } + for (int scale : new int[]{Integer.MIN_VALUE, Integer.MAX_VALUE}) { + Assert.assertThrows(IllegalArgumentException.class, () -> { + propertyKey.validValueOrThrow( + new BigDecimal(java.math.BigInteger.ONE, scale)); + }); + } Assert.assertThrows(IllegalArgumentException.class, () -> { propertyKey.validValueOrThrow(new BigDecimal("1E+999999999")); }); From 0bb7724a406d9ee49ab5bfee719aa440ee6ab002 Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Wed, 30 Sep 2026 13:05:30 +0000 Subject: [PATCH 07/11] fix(core): exact DECIMAL in property key userdata and list-API filters, IN/NOT_IN by value - PropertyKeyAPI reads user_data through UserdataDeserializer (the same token walk as PropertiesDeserializer), so a DECIMAL ~default_value given as a JSON number keeps every digit; BinarySerializer and TextSerializer reload userdata through JsonUtil.fromJsonExact, so it survives a restart. - API.parseProperties reads the properties filter exactly and the vertex and edge list APIs normalise each plain value to its property key's type (API.normalizeProperties) before building the traversal. - Condition.RelationType.IN/NOT_IN (core and struct copies) match numbers by value once a BigDecimal is involved, like EQ; other numbers keep the contains() semantics. - Tests: PropertiesDeserializerTest (userdata), JsonUtilTest (fromJsonExact), ConditionTest in core and struct, VertexApiTest (numeric filter literal, exact default value on create and after reload). Co-Authored-By: Claude Opus 4.8 --- .../java/org/apache/hugegraph/api/API.java | 32 +++++++++++- .../apache/hugegraph/api/graph/EdgeAPI.java | 1 + .../apache/hugegraph/api/graph/VertexAPI.java | 1 + .../hugegraph/api/schema/PropertyKeyAPI.java | 2 + .../api/schema/UserdataDeserializer.java | 52 +++++++++++++++++++ .../hugegraph/backend/query/Condition.java | 28 +++++++++- .../backend/serializer/BinarySerializer.java | 4 +- .../backend/serializer/TextSerializer.java | 2 +- .../apache/hugegraph/schema/PropertyKey.java | 38 +++++++++++++- .../org/apache/hugegraph/util/JsonUtil.java | 20 +++++++ .../apache/hugegraph/api/VertexApiTest.java | 42 +++++++++++++++ .../api/graph/PropertiesDeserializerTest.java | 29 ++++++++++- .../hugegraph/unit/core/ConditionTest.java | 23 ++++++++ .../hugegraph/unit/util/JsonUtilTest.java | 8 +++ .../org/apache/hugegraph/query/Condition.java | 28 +++++++++- .../apache/hugegraph/query/ConditionTest.java | 19 +++++++ 16 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/UserdataDeserializer.java diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java index b50195b85d..99259e260e 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java @@ -30,6 +30,7 @@ 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; @@ -37,6 +38,7 @@ 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; @@ -222,6 +224,33 @@ protected static void checkUpdatingBody(Collection bodies) } @SuppressWarnings("unchecked") + /** + * Convert each plain filter value to the runtime type of its property + * key (a JSON fraction arrives as BigDecimal, an integer literal as + * Integer or Long): a DECIMAL value keeps every digit, a DOUBLE value + * becomes a double as before. Predicates (P.gt(...)), collections and + * values of unknown keys are left as they are. + */ + protected static void normalizeProperties(HugeGraph g, Map props) { + for (Map.Entry entry : props.entrySet()) { + Object value = entry.getValue(); + if (value == null || value instanceof Collection || value instanceof Map || + value instanceof P) { + continue; + } + PropertyKey pkey; + try { + pkey = g.propertyKey(entry.getKey()); + } catch (NotFoundException e) { + continue; + } + Object typed = pkey.validValue(value); + if (typed != null) { + entry.setValue(typed); + } + } + } + protected static Map parseProperties(String properties) { if (properties == null || properties.isEmpty()) { return ImmutableMap.of(); @@ -229,7 +258,8 @@ protected static Map parseProperties(String properties) { Map 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 } diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/EdgeAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/EdgeAPI.java index 429b7c1879..6be2466a70 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/EdgeAPI.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/EdgeAPI.java @@ -341,6 +341,7 @@ public String list(@Context GraphManager manager, } } + normalizeProperties(g, props); for (Map.Entry entry : props.entrySet()) { traversal = traversal.has(entry.getKey(), entry.getValue()); } diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/VertexAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/VertexAPI.java index e8db9b99d7..e035f1f1b7 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/VertexAPI.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/VertexAPI.java @@ -286,6 +286,7 @@ public String list(@Context GraphManager manager, } } + normalizeProperties(g, props); for (Map.Entry entry : props.entrySet()) { traversal = traversal.has(entry.getKey(), entry.getValue()); } diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/PropertyKeyAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/PropertyKeyAPI.java index 27d6ab1da2..64508a5e84 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/PropertyKeyAPI.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/PropertyKeyAPI.java @@ -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; @@ -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") diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/UserdataDeserializer.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/UserdataDeserializer.java new file mode 100644 index 0000000000..ecdf3557a0 --- /dev/null +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/UserdataDeserializer.java @@ -0,0 +1,52 @@ +/* + * 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.Map; + +import org.apache.hugegraph.api.graph.PropertiesDeserializer; +import org.apache.hugegraph.schema.Userdata; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +/** + * The userdata of a property key read like element properties: a JSON + * fraction becomes a BigDecimal with every digit, so a DECIMAL + * {@code ~default_value} reaches the property key exactly; everything else + * keeps Jackson's default types. + */ +public class UserdataDeserializer extends StdDeserializer { + + private static final long serialVersionUID = 1L; + + private static final PropertiesDeserializer PROPERTIES = new PropertiesDeserializer(); + + public UserdataDeserializer() { + super(Userdata.class); + } + + @Override + public Userdata deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + Map map = PROPERTIES.deserialize(parser, context); + return map == null ? null : new Userdata(map); + } +} diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/Condition.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/Condition.java index e77f2f0b1e..ca4b7196ed 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/Condition.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/Condition.java @@ -18,6 +18,7 @@ package org.apache.hugegraph.backend.query; import java.util.ArrayList; +import java.math.BigDecimal; import java.util.Collection; import java.util.Date; import java.util.List; @@ -77,12 +78,12 @@ public enum RelationType implements BiPredicate { IN("in", null, Collection.class, (v1, v2) -> { assert v2 != null; - return ((Collection) v2).contains(v1); + return collectionContains((Collection) v2, v1); }), NOT_IN("notin", null, Collection.class, (v1, v2) -> { assert v2 != null; - return !((Collection) v2).contains(v1); + return !collectionContains((Collection) v2, v1); }), PREFIX("prefix", Id.class, Id.class, (v1, v2) -> { @@ -280,6 +281,29 @@ public boolean test(Object first, Object second) { return this.tester.apply(first, second); } + /** + * Membership by value once a BigDecimal is involved: its equals() is + * scale sensitive (1.0 vs 1.00), while EQ compares decimals by value, + * so IN/NOT_IN do the same. Other numbers keep contains() semantics + * (an Integer 1 is not a Double 1.0 here, as before). + */ + private static boolean collectionContains(Collection values, Object value) { + if (values.contains(value)) { + return true; + } + if (!(value instanceof Number)) { + return false; + } + boolean decimal = value instanceof BigDecimal; + for (Object member : values) { + if (member instanceof Number && (decimal || member instanceof BigDecimal) && + NumericUtil.compareNumber(value, (Number) member) == 0) { + return true; + } + } + return false; + } + public boolean isRangeType() { return RANGE_TYPES.contains(this); } diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BinarySerializer.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BinarySerializer.java index 0bb07760a5..92038e47ba 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BinarySerializer.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BinarySerializer.java @@ -1237,8 +1237,8 @@ private void readUserdata(SchemaElement schema) { byte[] userdataBytes = column(HugeKeys.USER_DATA); String userdataStr = StringEncoding.decode(userdataBytes); @SuppressWarnings("unchecked") - Map userdata = JsonUtil.fromJson(userdataStr, - Map.class); + Map userdata = JsonUtil.fromJsonExact(userdataStr, + Map.class); for (Map.Entry e : userdata.entrySet()) { schema.userdata(e.getKey(), e.getValue()); } diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/TextSerializer.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/TextSerializer.java index 2d5cb81ec1..9276090fb9 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/TextSerializer.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/TextSerializer.java @@ -911,7 +911,7 @@ private static void readUserdata(SchemaElement schema, // Parse all user data of a schema element String userdataStr = entry.column(HugeKeys.USER_DATA); @SuppressWarnings("unchecked") - Map userdata = JsonUtil.fromJson(userdataStr, + Map userdata = JsonUtil.fromJsonExact(userdataStr, Map.class); for (Map.Entry e : userdata.entrySet()) { schema.userdata(e.getKey(), e.getValue()); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java index 18b93aebaf..8d107aa351 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java @@ -116,7 +116,43 @@ public PropertyKey properties(Id... properties) { public void defineDefaultValue(Object value) { // TODO add a field default_value - this.userdata().put(Userdata.DEFAULT_VALUE, value); + this.userdata().put(Userdata.DEFAULT_VALUE, this.normalizeDefaultValue(value)); + } + + /** + * The default value is kept in userdata in the runtime type of this + * key's data type (a JSON fraction arrives as BigDecimal: a DOUBLE key + * keeps a Double, a DECIMAL key the exact BigDecimal), both when the key + * is defined through the API and when it is read back from the backend, + * so the value serializes the same way on every path. + */ + @Override + public void userdata(String key, Object value) { + if (Userdata.DEFAULT_VALUE.equals(key)) { + value = this.normalizeDefaultValue(value); + } + super.userdata(key, value); + } + + @Override + public void userdata(Userdata userdata) { + E.checkArgumentNotNull(userdata, "userdata"); + for (Map.Entry e : userdata.entrySet()) { + this.userdata(e.getKey(), e.getValue()); + } + } + + private Object normalizeDefaultValue(Object value) { + if (value == null || this.dataType == null) { + return value; + } + Object raw = value; + if (this.cardinality == Cardinality.SET && value instanceof Collection && + !(value instanceof Set)) { + raw = new LinkedHashSet<>((Collection) value); + } + Object valid = this.validValue(raw); + return valid != null ? valid : value; } public Object defaultValue() { diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java index b0440fe203..e9b8890336 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/util/JsonUtil.java @@ -26,6 +26,7 @@ import org.apache.tinkerpop.shaded.jackson.core.JsonProcessingException; import org.apache.tinkerpop.shaded.jackson.core.type.TypeReference; import org.apache.tinkerpop.shaded.jackson.databind.Module; +import org.apache.tinkerpop.shaded.jackson.databind.DeserializationFeature; import org.apache.tinkerpop.shaded.jackson.databind.ObjectMapper; import org.apache.tinkerpop.shaded.jackson.databind.ObjectReader; import org.apache.tinkerpop.shaded.jackson.databind.SerializationFeature; @@ -81,6 +82,25 @@ public static T fromJson(String json, Class clazz) { } } + /** + * Like {@link #fromJson(String, Class)}, but a JSON fraction becomes a + * BigDecimal with every digit instead of a double: for JSON whose + * fractions may be DECIMAL property values (a property key's userdata + * with its default value, the properties filter of the list APIs). + */ + public static T fromJsonExact(String json, Class clazz) { + E.checkState(json != null, + "Json value can't be null for '%s'", + clazz.getSimpleName()); + try { + return MAPPER.readerFor(clazz) + .with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .readValue(json); + } catch (IOException e) { + throw new HugeException("Can't read json: %s", e, e.getMessage()); + } + } + public static T fromJson(String json, TypeReference typeRef) { E.checkState(json != null, "Json value can't be null for '%s'", diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java index 28bfdb3639..14408eb800 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java @@ -207,6 +207,48 @@ public void testDecimalJsonNumberLiteralIsExact() throws IOException { StandardCharsets.UTF_8))); content = assertResponseStatus(200, r); Assert.assertEquals("{\"edges\":[]}", content); + // the same filter as JSON number literals: parsed exactly, not as a + // double, so the exact value hits and the one-digit change misses + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pay", + "properties", URLEncoder.encode("{\"amount\":" + literal + "}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertContains("\"amount\":\"" + literal + "\"", content); + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pay", + "properties", URLEncoder.encode("{\"amount\":" + near + "}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertEquals("{\"edges\":[]}", content); + + // a DECIMAL default value given as a JSON number keeps every digit, + // on create and after the schema is read back from the backend + String fee = "0.1234567890123456789"; + createAndAssert(URL_PREFIX + "/schema/propertykeys", + "{" + + "\"name\": \"fee\"," + + "\"data_type\": \"DECIMAL\"," + + "\"cardinality\": \"SINGLE\"," + + "\"check_exist\": false," + + "\"user_data\": {\"~default_value\": " + fee + "}," + + "\"properties\":[]" + + "}", 202); + r = client().get(URL_PREFIX + "/schema/propertykeys/", "fee"); + content = assertResponseStatus(200, r); + Assert.assertContains("\"~default_value\":\"" + fee + "\"", content); + createAndAssert(URL_PREFIX + "/schema/vertexlabels", + "{" + + "\"primary_keys\":[\"name\"]," + + "\"id_strategy\": \"PRIMARY_KEY\"," + + "\"name\": \"fees\"," + + "\"properties\":[\"name\", \"fee\"]," + + "\"check_exist\": false," + + "\"nullable_keys\":[\"fee\"]" + + "}"); + r = client().post(PATH, "{\"label\":\"fees\",\"properties\":{\"name\":\"f1\"}}"); + content = assertResponseStatus(201, r); + Assert.assertContains("\"fee\":\"" + fee + "\"", content); // a fraction elsewhere in a body keeps its usual type: a DOUBLE key // with a fractional default value round-trips as a JSON number diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java index 26b719daaf..55f2a2ed08 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java @@ -22,6 +22,8 @@ import java.util.Map; import org.apache.hugegraph.api.graph.PropertiesDeserializer; +import org.apache.hugegraph.api.schema.UserdataDeserializer; +import org.apache.hugegraph.schema.Userdata; import org.apache.hugegraph.testutil.Assert; import org.junit.Test; @@ -78,13 +80,36 @@ public void testFractionsAreExactInPropertiesOnly() throws Exception { Assert.assertEquals(0.85d, body.options.get("alpha")); } + public static class KeyBody { + + @JsonProperty("name") + public String name; + @JsonProperty("user_data") + @JsonDeserialize(using = UserdataDeserializer.class) + public Userdata userdata; + } + + /** The same reading on a property key's user_data (PropertyKeyAPI). */ + @Test + public void testPropertyKeyUserdataIsExact() throws Exception { + KeyBody key = MAPPER.readValue( + "{\"name\":\"fee\"," + + "\"user_data\":{\"~default_value\":0.1234567890123456789,\"note\":\"x\"," + + "\"weight\":2}}", KeyBody.class); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), + key.userdata.get("~default_value")); + Assert.assertEquals("x", key.userdata.get("note")); + Assert.assertEquals(2, key.userdata.get("weight")); + key = MAPPER.readValue("{\"name\":\"fee\"}", KeyBody.class); + Assert.assertNull(key.userdata); + } + @Test public void testNullAndNonObject() throws Exception { Body body = MAPPER.readValue("{\"label\":\"x\",\"properties\":null}", Body.class); Assert.assertNull(body.properties); - Assert.assertThrows(com.fasterxml.jackson.databind.JsonMappingException.class, - () -> { + Assert.assertThrows(com.fasterxml.jackson.databind.JsonMappingException.class, () -> { MAPPER.readValue("{\"label\":\"x\",\"properties\":[1]}", Body.class); }); } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/ConditionTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/ConditionTest.java index ba4b09dcab..93d9543372 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/ConditionTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/ConditionTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.unit.core; +import java.math.BigDecimal; import java.util.Date; import org.apache.hugegraph.backend.id.IdGenerator; @@ -395,6 +396,28 @@ public void testConditionIn() { }); } + @Test + public void testConditionInMatchesNumbersByValue() { + // BigDecimal.equals() is scale sensitive; IN/NOT_IN compare by value + // like EQ, so a stored 1.0 is within (1.00, 2.5) on every backend + Condition c = new SyspropRelation(HugeKeys.ID, RelationType.IN, + ImmutableList.of(new BigDecimal("1.00"), + new BigDecimal("2.5"))); + Assert.assertTrue(c.test(new BigDecimal("1.0"))); + Assert.assertTrue(c.test(new BigDecimal("1"))); + Assert.assertTrue(c.test(1)); + Assert.assertTrue(c.test(2.5d)); + Assert.assertFalse(c.test(new BigDecimal("1.01"))); + Assert.assertFalse(c.test("1.0")); + + Condition n = new SyspropRelation(HugeKeys.ID, RelationType.NOT_IN, + ImmutableList.of(new BigDecimal("1.00"))); + Assert.assertFalse(n.test(new BigDecimal("1.0"))); + Assert.assertFalse(n.test(1L)); + Assert.assertTrue(n.test(new BigDecimal("1.1"))); + Assert.assertTrue(n.test("1.0")); + } + @Test public void testConditionNotIn() { Condition c1 = Condition.nin(HugeKeys.ID, ImmutableList.of(1, 2, "3")); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java index 024f00e750..42e9bfeff1 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/JsonUtilTest.java @@ -349,6 +349,14 @@ public void testSerializeBigDecimal() { Assert.assertEquals(0.85d, JsonUtil.fromJson("{\"alpha\":0.85}", Map.class).get("alpha")); + // fromJsonExact: a fraction in an untyped map keeps every digit + Map exact = JsonUtil.fromJsonExact( + "{\"amount\":12345678901234567890.123456789012345678,\"n\":7}", Map.class); + Assert.assertEquals(new BigDecimal("12345678901234567890.123456789012345678"), + exact.get("amount")); + Assert.assertEquals(7, exact.get("n")); + Assert.assertEquals(0.1d, JsonUtil.fromJson("{\"w\":0.1}", Map.class).get("w")); + // both a string and a number literal are accepted on the way in Assert.assertEquals(new BigDecimal("1.5"), JsonUtil.fromJson("\"1.5\"", BigDecimal.class)); diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/query/Condition.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/query/Condition.java index 415405a1bd..5fa0b71c0b 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/query/Condition.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/query/Condition.java @@ -31,6 +31,7 @@ import org.apache.hugegraph.util.E; import org.apache.hugegraph.util.NumericUtil; +import java.math.BigDecimal; import java.util.*; import java.util.function.BiFunction; import java.util.function.BiPredicate; @@ -225,12 +226,12 @@ public enum RelationType implements BiPredicate { IN("in", null, Collection.class, (v1, v2) -> { assert v2 != null; - return ((Collection) v2).contains(v1); + return collectionContains((Collection) v2, v1); }), NOT_IN("notin", null, Collection.class, (v1, v2) -> { assert v2 != null; - return !((Collection) v2).contains(v1); + return !collectionContains((Collection) v2, v1); }), PREFIX("prefix", Id.class, Id.class, (v1, v2) -> { @@ -568,6 +569,29 @@ public boolean isFuzzyType() { this == TEXT_MATCH_EDIT_DISTANCE; } + /** + * Membership by value once a BigDecimal is involved: its equals() is + * scale sensitive (1.0 vs 1.00), while EQ compares decimals by value, + * so IN/NOT_IN do the same. Other numbers keep contains() semantics + * (an Integer 1 is not a Double 1.0 here, as before). + */ + private static boolean collectionContains(Collection values, Object value) { + if (values.contains(value)) { + return true; + } + if (!(value instanceof Number)) { + return false; + } + boolean decimal = value instanceof BigDecimal; + for (Object member : values) { + if (member instanceof Number && (decimal || member instanceof BigDecimal) && + NumericUtil.compareNumber(value, (Number) member) == 0) { + return true; + } + } + return false; + } + public boolean isRangeType() { return ImmutableSet.of(GT, GTE, LT, LTE).contains(this); } diff --git a/hugegraph-struct/src/test/java/org/apache/hugegraph/query/ConditionTest.java b/hugegraph-struct/src/test/java/org/apache/hugegraph/query/ConditionTest.java index b34fa4e735..8aa3dbd31d 100644 --- a/hugegraph-struct/src/test/java/org/apache/hugegraph/query/ConditionTest.java +++ b/hugegraph-struct/src/test/java/org/apache/hugegraph/query/ConditionTest.java @@ -17,12 +17,31 @@ package org.apache.hugegraph.query; +import java.math.BigDecimal; +import java.util.Arrays; + import org.apache.hugegraph.type.define.HugeKeys; import org.junit.Assert; import org.junit.Test; public class ConditionTest { + @Test + public void testConditionInMatchesNumbersByValue() { + Condition c = new Condition.SyspropRelation(HugeKeys.ID, Condition.RelationType.IN, + Arrays.asList(new BigDecimal("1.00"), + new BigDecimal("2.5"))); + Assert.assertTrue(c.test(new BigDecimal("1.0"))); + Assert.assertTrue(c.test(1)); + Assert.assertTrue(c.test(2.5d)); + Assert.assertFalse(c.test(new BigDecimal("1.01"))); + Assert.assertFalse(c.test("1.0")); + Condition n = new Condition.SyspropRelation(HugeKeys.ID, Condition.RelationType.NOT_IN, + Arrays.asList(new BigDecimal("1.00"))); + Assert.assertFalse(n.test(new BigDecimal("1.0"))); + Assert.assertTrue(n.test(new BigDecimal("1.1"))); + } + @Test public void testConditionBooleanRange() { Condition lt = Condition.lt(HugeKeys.ID, true); From d7eb80c82ae0451f09dd994caf402680b186bd65 Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Thu, 1 Oct 2026 08:59:32 +0000 Subject: [PATCH 08/11] fix(core): exact DECIMAL predicate operands, cardinality-aware filter normalisation, exact userdata only for the default value, decimal lists in store scans - TraversalUtil.predicateNumber/predicateArg(s) read operands exactly: a fractional literal in P.eq(...)/P.within(...) becomes a BigDecimal and is converted to the property key's type by validPropertyValue when the condition is built (a DOUBLE key still gets a double). - API.normalizeProperties delegates to TraversalUtil.validPropertyValue, so a list on a single key converts every member and a scalar on a LIST/SET key keeps its membership semantics. - UserdataDeserializer reads only ~default_value exactly; other metadata keeps Jackson's types (a {"rate":0.85} stays a JSON number). - GraphStoreIterator encodes a DECIMAL LIST/SET as a JSON array of plain strings instead of casting the collection. - Tests: TraversalUtilTest (fractional operands), PropertiesDeserializerTest (metadata types), VertexApiTest (exact P.eq/P.within filter, DECIMAL LIST key filters, custom userdata fraction round-trips as a number). Co-Authored-By: Claude Opus 4.8 --- .../java/org/apache/hugegraph/api/API.java | 22 ++--- .../api/graph/PropertiesDeserializer.java | 3 +- .../api/schema/UserdataDeserializer.java | 35 +++++-- .../apache/hugegraph/schema/PropertyKey.java | 44 +++++++-- .../traversal/optimize/TraversalUtil.java | 10 +- .../apache/hugegraph/api/VertexApiTest.java | 77 +++++++++++++++- .../apache/hugegraph/unit/UnitTestSuite.java | 2 + .../api/graph/PropertiesDeserializerTest.java | 9 +- .../unit/core/PropertyKeyUserdataTest.java | 91 +++++++++++++++++++ .../unit/core/TraversalUtilTest.java | 33 +++++-- .../store/business/GraphStoreIterator.java | 19 +++- 11 files changed, 302 insertions(+), 43 deletions(-) create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/PropertyKeyUserdataTest.java diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java index 99259e260e..c1ee404af8 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java @@ -34,6 +34,7 @@ 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; @@ -225,17 +226,19 @@ protected static void checkUpdatingBody(Collection bodies) @SuppressWarnings("unchecked") /** - * Convert each plain filter value to the runtime type of its property - * key (a JSON fraction arrives as BigDecimal, an integer literal as - * Integer or Long): a DECIMAL value keeps every digit, a DOUBLE value - * becomes a double as before. Predicates (P.gt(...)), collections and - * values of unknown keys are left as they are. + * 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 props) { for (Map.Entry entry : props.entrySet()) { Object value = entry.getValue(); - if (value == null || value instanceof Collection || value instanceof Map || - value instanceof P) { + if (value == null || value instanceof P) { continue; } PropertyKey pkey; @@ -244,10 +247,7 @@ protected static void normalizeProperties(HugeGraph g, Map props } catch (NotFoundException e) { continue; } - Object typed = pkey.validValue(value); - if (typed != null) { - entry.setValue(typed); - } + entry.setValue(TraversalUtil.validPropertyValue(value, pkey)); } } diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java index 4c3959d454..0a188caace 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java @@ -74,7 +74,8 @@ private static List readArray(JsonParser parser) return array; } - private static Object readValue(JsonParser parser) throws IOException { + /** 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: diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/UserdataDeserializer.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/UserdataDeserializer.java index ecdf3557a0..887c4a374f 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/UserdataDeserializer.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/schema/UserdataDeserializer.java @@ -18,27 +18,29 @@ 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 read like element properties: a JSON - * fraction becomes a BigDecimal with every digit, so a DECIMAL - * {@code ~default_value} reaches the property key exactly; everything else - * keeps Jackson's default types. + * 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 { private static final long serialVersionUID = 1L; - private static final PropertiesDeserializer PROPERTIES = new PropertiesDeserializer(); - public UserdataDeserializer() { super(Userdata.class); } @@ -46,7 +48,24 @@ public UserdataDeserializer() { @Override public Userdata deserialize(JsonParser parser, DeserializationContext context) throws IOException { - Map map = PROPERTIES.deserialize(parser, context); - return map == null ? null : new Userdata(map); + 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 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); } } diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java index 8d107aa351..cf7b92f2b5 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.schema; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -120,12 +121,30 @@ public void defineDefaultValue(Object value) { } /** - * The default value is kept in userdata in the runtime type of this - * key's data type (a JSON fraction arrives as BigDecimal: a DOUBLE key - * keeps a Double, a DECIMAL key the exact BigDecimal), both when the key - * is defined through the API and when it is read back from the backend, - * so the value serializes the same way on every path. + * A DECIMAL key keeps its default value in userdata as the exact + * BigDecimal (a JSON fraction arrives as BigDecimal, a string is parsed), + * both when the key is defined through the API and when it is read back + * from the backend, so the value serializes the same way on every path. + * Every other data type keeps the raw value the user sent, as before: + * {@link #defaultValue()} converts it lazily when it is applied; only a + * BigDecimal (which exists solely because the API reads the default + * exactly) becomes the Double the default parser produced on master. */ + /** A BigDecimal from the exact parser as the Double the default parser gave. */ + private static Object undoExact(Object value) { + if (value instanceof BigDecimal) { + return ((BigDecimal) value).doubleValue(); + } + if (value instanceof Collection) { + List values = new ArrayList<>(((Collection) value).size()); + for (Object member : (Collection) value) { + values.add(undoExact(member)); + } + return value instanceof Set ? new LinkedHashSet<>(values) : values; + } + return value; + } + @Override public void userdata(String key, Object value) { if (Userdata.DEFAULT_VALUE.equals(key)) { @@ -143,16 +162,25 @@ public void userdata(Userdata userdata) { } private Object normalizeDefaultValue(Object value) { - if (value == null || this.dataType == null) { + if (value == null) { return value; } + if (this.dataType != DataType.DECIMAL) { + return undoExact(value); + } Object raw = value; if (this.cardinality == Cardinality.SET && value instanceof Collection && !(value instanceof Set)) { raw = new LinkedHashSet<>((Collection) value); } - Object valid = this.validValue(raw); - return valid != null ? valid : value; + try { + Object valid = this.validValue(raw); + return valid != null ? valid : value; + } catch (RuntimeException e) { + // An already stored default that does not convert keeps loading; + // the error surfaces when the default is applied, as before + return value; + } } public Object defaultValue() { diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/traversal/optimize/TraversalUtil.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/traversal/optimize/TraversalUtil.java index e6a56027a1..861a59e743 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/traversal/optimize/TraversalUtil.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/traversal/optimize/TraversalUtil.java @@ -1254,7 +1254,7 @@ public static Query.Order convOrder(Order order) { return order == Order.desc ? Query.Order.DESC : Query.Order.ASC; } - private static V validPropertyValue(V value, PropertyKey pkey) { + public static V validPropertyValue(V value, PropertyKey pkey) { if (pkey.cardinality().single() && value instanceof Collection && !pkey.dataType().isBlob()) { // Expect single but got collection, like P.within([]) @@ -1488,7 +1488,9 @@ public static Condition parsePredicate(PropertyKey pk, String predicate) { private static Number predicateNumber(String value) { try { - return JsonUtil.fromJson(value, Number.class); + // Exact: a fraction becomes a BigDecimal with every digit and + // is converted to the property key's type by validPropertyValue + return JsonUtil.fromJsonExact(value, Number.class); } catch (Exception e) { // Try to parse date if (e.getMessage().contains("not a valid number") || @@ -1537,7 +1539,7 @@ private static Number[] predicateNumbers(String value, int count) { @SuppressWarnings("unchecked") private static V predicateArg(String value) { try { - return (V) JsonUtil.fromJson(value, Object.class); + return (V) JsonUtil.fromJsonExact(value, Object.class); } catch (Exception e) { throw new HugeException( "Invalid value '%s', expect a single value", e, value); @@ -1547,7 +1549,7 @@ private static V predicateArg(String value) { @SuppressWarnings("unchecked") private static List predicateArgs(String value) { try { - return JsonUtil.fromJson("[" + value + "]", List.class); + return JsonUtil.fromJsonExact("[" + value + "]", List.class); } catch (Exception e) { throw new HugeException( "Invalid value '%s', expect a list", e, value); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java index 14408eb800..0489b21eee 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java @@ -222,6 +222,65 @@ public void testDecimalJsonNumberLiteralIsExact() throws IOException { content = assertResponseStatus(200, r); Assert.assertEquals("{\"edges\":[]}", content); + // a predicate with a fractional operand is exact as well + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pay", + "properties", URLEncoder.encode("{\"amount\":\"P.eq(" + literal + ")\"}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertContains("\"amount\":\"" + literal + "\"", content); + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pay", + "properties", URLEncoder.encode("{\"amount\":\"P.eq(" + near + ")\"}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertEquals("{\"edges\":[]}", content); + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pay", + "properties", URLEncoder.encode("{\"amount\":\"P.within(" + near + "," + + literal + ")\"}", StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertContains("\"amount\":\"" + literal + "\"", content); + + // a LIST key: a list filter converts every member, a scalar filter + // keeps membership semantics + createAndAssert(URL_PREFIX + "/schema/propertykeys", + "{" + + "\"name\": \"amounts\"," + + "\"data_type\": \"DECIMAL\"," + + "\"cardinality\": \"LIST\"," + + "\"check_exist\": false," + + "\"properties\":[]" + + "}", 202); + createAndAssert(URL_PREFIX + "/schema/edgelabels", + "{" + + "\"name\": \"pays\"," + + "\"source_label\": \"person\"," + + "\"target_label\": \"person\"," + + "\"frequency\": \"SINGLE\"," + + "\"properties\":[\"amounts\"]," + + "\"nullable_keys\":[\"amounts\"]," + + "\"check_exist\": false" + + "}"); + content = assertResponseStatus(201, client().post(URL_PREFIX + "/graph/edges/", + "{\"label\":\"pays\",\"outVLabel\":\"person\"," + + "\"inVLabel\":\"person\",\"outV\":\"" + payer + "\"," + + "\"inV\":\"" + payee + "\"," + + "\"properties\":{\"amounts\":[" + literal + ", 1.0]}}")); + Assert.assertContains("\"amounts\":[\"" + literal + "\",\"1.0\"]", content); + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pays", + "properties", URLEncoder.encode("{\"amounts\":[" + literal + ",1.0]}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertContains("\"amounts\":[\"" + literal + "\",\"1.0\"]", content); + r = client().get(edges, ImmutableMap.of( + "vertex_id", id2Json(payer), "direction", "OUT", "label", "pays", + "properties", URLEncoder.encode("{\"amounts\":" + near + "}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertEquals("{\"edges\":[]}", content); + // a DECIMAL default value given as a JSON number keeps every digit, // on create and after the schema is read back from the backend String fee = "0.1234567890123456789"; @@ -258,12 +317,28 @@ public void testDecimalJsonNumberLiteralIsExact() throws IOException { "\"data_type\": \"DOUBLE\"," + "\"cardinality\": \"SINGLE\"," + "\"check_exist\": false," + - "\"user_data\": {\"~default_value\": 1.5}," + + "\"user_data\": {\"~default_value\": 1.5, \"rate\": 0.85}," + "\"properties\":[]" + "}", 202); r = client().get(URL_PREFIX + "/schema/propertykeys/", "ratio"); content = assertResponseStatus(200, r); Assert.assertContains("\"~default_value\":1.5", content); + // custom metadata keeps its type: a JSON number, not a string + Assert.assertContains("\"rate\":0.85", content); + // a default of another type keeps the form the user sent (a DATE + // default is converted only when it is applied, as on master) + createAndAssert(URL_PREFIX + "/schema/propertykeys", + "{" + + "\"name\": \"day\"," + + "\"data_type\": \"DATE\"," + + "\"cardinality\": \"SINGLE\"," + + "\"check_exist\": false," + + "\"user_data\": {\"~default_value\": \"2020-01-01\"}," + + "\"properties\":[]" + + "}", 202); + r = client().get(URL_PREFIX + "/schema/propertykeys/", "day"); + content = assertResponseStatus(200, r); + Assert.assertContains("\"~default_value\":\"2020-01-01\"", content); // integer keys still reject a fraction, with the usual message vertex = "{" + diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index 5ec64d0b25..ee642662f2 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -69,6 +69,7 @@ import org.apache.hugegraph.unit.core.ServerInfoManagerTest; import org.apache.hugegraph.unit.core.SystemSchemaStoreTest; import org.apache.hugegraph.unit.core.TaskSchedulerServerInfoTest; +import org.apache.hugegraph.unit.core.PropertyKeyUserdataTest; import org.apache.hugegraph.unit.core.TraversalUtilTest; import org.apache.hugegraph.unit.id.EdgeIdTest; import org.apache.hugegraph.unit.id.IdTest; @@ -168,6 +169,7 @@ HstoreSessionsTest.class, BackendStoreInfoTest.class, TraversalUtilTest.class, + PropertyKeyUserdataTest.class, TraversalUtilOptimizeTest.class, IdHolderTest.class, PageStateTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java index 55f2a2ed08..f79cd61459 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java @@ -95,11 +95,18 @@ public void testPropertyKeyUserdataIsExact() throws Exception { KeyBody key = MAPPER.readValue( "{\"name\":\"fee\"," + "\"user_data\":{\"~default_value\":0.1234567890123456789,\"note\":\"x\"," + - "\"weight\":2}}", KeyBody.class); + "\"weight\":2,\"rate\":0.85,\"tags\":[1.5]}}", KeyBody.class); Assert.assertEquals(new BigDecimal("0.1234567890123456789"), key.userdata.get("~default_value")); Assert.assertEquals("x", key.userdata.get("note")); Assert.assertEquals(2, key.userdata.get("weight")); + // only the default value is exact: other metadata keeps its types + Assert.assertEquals(0.85d, key.userdata.get("rate")); + Assert.assertEquals(1.5d, ((List) key.userdata.get("tags")).get(0)); + key = MAPPER.readValue("{\"name\":\"fee\",\"user_data\":{\"~default_value\":[1.5, 2]}}", + KeyBody.class); + Assert.assertEquals(new BigDecimal("1.5"), + ((List) key.userdata.get("~default_value")).get(0)); key = MAPPER.readValue("{\"name\":\"fee\"}", KeyBody.class); Assert.assertNull(key.userdata); } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/PropertyKeyUserdataTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/PropertyKeyUserdataTest.java new file mode 100644 index 0000000000..6c4790902b --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/PropertyKeyUserdataTest.java @@ -0,0 +1,91 @@ +/* + * 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.unit.core; + +import java.math.BigDecimal; +import java.util.Arrays; + +import org.apache.hugegraph.backend.id.IdGenerator; +import org.apache.hugegraph.schema.PropertyKey; +import org.apache.hugegraph.schema.Userdata; +import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.type.define.Cardinality; +import org.apache.hugegraph.type.define.DataType; +import org.junit.Test; + +/** + * Only a DECIMAL key normalizes its ~default_value eagerly; every other type + * keeps the raw value the user sent (and an unconvertible stored default + * still loads), exactly as on master. + */ +public class PropertyKeyUserdataTest { + + private static PropertyKey key(DataType type, Cardinality cardinality) { + PropertyKey pkey = new PropertyKey(null, IdGenerator.of(1), "k"); + pkey.dataType(type); + pkey.cardinality(cardinality); + return pkey; + } + + @Test + public void testDecimalDefaultIsExact() { + PropertyKey pkey = key(DataType.DECIMAL, Cardinality.SINGLE); + pkey.userdata(Userdata.DEFAULT_VALUE, new BigDecimal("0.1234567890123456789")); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), + pkey.userdata().get(Userdata.DEFAULT_VALUE)); + pkey.userdata(Userdata.DEFAULT_VALUE, "1.50"); + Assert.assertEquals(new BigDecimal("1.50"), pkey.userdata().get(Userdata.DEFAULT_VALUE)); + PropertyKey list = key(DataType.DECIMAL, Cardinality.LIST); + list.userdata(Userdata.DEFAULT_VALUE, Arrays.asList(new BigDecimal("1.5"), 2)); + Assert.assertEquals(Arrays.asList(new BigDecimal("1.5"), new BigDecimal("2")), + list.userdata().get(Userdata.DEFAULT_VALUE)); + // an unconvertible default is kept raw instead of failing the load + pkey.userdata(Userdata.DEFAULT_VALUE, "not-a-number"); + Assert.assertEquals("not-a-number", pkey.userdata().get(Userdata.DEFAULT_VALUE)); + } + + @Test + public void testOtherTypesKeepTheRawDefault() { + PropertyKey date = key(DataType.DATE, Cardinality.SINGLE); + date.userdata(Userdata.DEFAULT_VALUE, "2020-01-01"); + Assert.assertEquals("2020-01-01", date.userdata().get(Userdata.DEFAULT_VALUE)); + date.userdata(Userdata.DEFAULT_VALUE, "not-a-date"); + Assert.assertEquals("not-a-date", date.userdata().get(Userdata.DEFAULT_VALUE)); + + PropertyKey ints = key(DataType.INT, Cardinality.LIST); + ints.userdata(Userdata.DEFAULT_VALUE, 1); + Assert.assertEquals(1, ints.userdata().get(Userdata.DEFAULT_VALUE)); + + PropertyKey dbl = key(DataType.DOUBLE, Cardinality.SINGLE); + Userdata userdata = new Userdata(); + userdata.put(Userdata.DEFAULT_VALUE, 1.5d); + userdata.put("rate", 0.85d); + dbl.userdata(userdata); + Assert.assertEquals(1.5d, dbl.userdata().get(Userdata.DEFAULT_VALUE)); + Assert.assertEquals(0.85d, dbl.userdata().get("rate")); + // the API reads the default exactly; for a non-decimal key that + // BigDecimal becomes the Double the parser gave on master + dbl.userdata(Userdata.DEFAULT_VALUE, new BigDecimal("1.5")); + Assert.assertEquals(1.5d, dbl.userdata().get(Userdata.DEFAULT_VALUE)); + PropertyKey dbls = key(DataType.DOUBLE, Cardinality.LIST); + dbls.userdata(Userdata.DEFAULT_VALUE, Arrays.asList(new BigDecimal("1.5"), 2)); + Assert.assertEquals(Arrays.asList(1.5d, 2), dbls.userdata().get(Userdata.DEFAULT_VALUE)); + ints.userdata(Userdata.DEFAULT_VALUE, new BigDecimal("1.5")); + Assert.assertEquals(1.5d, ints.userdata().get(Userdata.DEFAULT_VALUE)); + } +} diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/TraversalUtilTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/TraversalUtilTest.java index 08322112b7..923c394a65 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/TraversalUtilTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/TraversalUtilTest.java @@ -17,6 +17,8 @@ package org.apache.hugegraph.unit.core; +import java.math.BigDecimal; + import org.apache.hugegraph.HugeException; import org.apache.hugegraph.testutil.Assert; import org.apache.hugegraph.traversal.optimize.TraversalUtil; @@ -46,44 +48,44 @@ public void testParsePredicate() { TraversalUtil.parsePredicate("P.lt(-1)")); Assert.assertEquals(P.lt(-1), TraversalUtil.parsePredicate("P.lt(\"-1\")")); - Assert.assertEquals(P.lte(-123.45), + Assert.assertEquals(P.lte(new BigDecimal("-123.45")), TraversalUtil.parsePredicate("P.lte(-123.45)")); - Assert.assertEquals(P.lte(3.14), + Assert.assertEquals(P.lte(new BigDecimal("3.14")), TraversalUtil.parsePredicate("P.lte(\"3.14\")")); Assert.assertEquals(P.gt(18), TraversalUtil.parsePredicate("P.gt(18)")); Assert.assertEquals(P.gt(18), TraversalUtil.parsePredicate("P.gt(\"18\")")); - Assert.assertEquals(P.gte(3.14), + Assert.assertEquals(P.gte(new BigDecimal("3.14")), TraversalUtil.parsePredicate("P.gte(3.14)")); - Assert.assertEquals(P.gte(3.14), + Assert.assertEquals(P.gte(new BigDecimal("3.14")), TraversalUtil.parsePredicate("P.gte(\"3.14\")")); Assert.assertEquals(P.between(1, 100), TraversalUtil.parsePredicate("P.between(1, 100)")); - Assert.assertEquals(P.between(1, 1.2), + Assert.assertEquals(P.between(1, new BigDecimal("1.2")), TraversalUtil.parsePredicate("P.between(1, 1.2)")); Assert.assertEquals(P.between(1, 2), TraversalUtil.parsePredicate("P.between(\"1\", 2)")); Assert.assertEquals(P.inside(1, 100), TraversalUtil.parsePredicate("P.inside(1, 100)")); - Assert.assertEquals(P.inside(0.28, 1), + Assert.assertEquals(P.inside(new BigDecimal("0.28"), 1), TraversalUtil.parsePredicate("P.inside(0.28, 1)")); Assert.assertEquals(P.inside(1, 2), TraversalUtil.parsePredicate("P.inside(\"1\", 2)")); Assert.assertEquals(P.outside(1, 100), TraversalUtil.parsePredicate("P.outside(1, 100)")); - Assert.assertEquals(P.outside(1, 1.5), + Assert.assertEquals(P.outside(1, new BigDecimal("1.5")), TraversalUtil.parsePredicate("P.outside(1, 1.5)")); Assert.assertEquals(P.outside(1, 2), TraversalUtil.parsePredicate("P.outside(\"1\", 2)")); Assert.assertEquals(P.within(1, 3, 5), TraversalUtil.parsePredicate("P.within(1, 3, 5)")); - Assert.assertEquals(P.within("abc", "hello", (Object) 123, 3.14), + Assert.assertEquals(P.within("abc", "hello", (Object) 123, new BigDecimal("3.14")), TraversalUtil.parsePredicate( "P.within(\"abc\", \"hello\", 123, 3.14)")); } @@ -335,4 +337,19 @@ public void testParsePredicateWithInvalidString() { "expect a list", e.getMessage()); }); } + + /** A fractional operand keeps every digit; the key's type converts it later. */ + @Test + public void testParsePredicateFractionIsExact() { + Assert.assertEquals(P.eq(new BigDecimal("0.100000000000000001")), + TraversalUtil.parsePredicate("P.eq(0.100000000000000001)")); + Assert.assertEquals(P.gt(new BigDecimal("12345678901234567890.123456789012345678")), + TraversalUtil.parsePredicate( + "P.gt(12345678901234567890.123456789012345678)")); + Assert.assertEquals(P.between(new BigDecimal("0.1"), new BigDecimal("0.2")), + TraversalUtil.parsePredicate("P.between(0.1, 0.2)")); + Assert.assertEquals(P.within(new BigDecimal("1.00"), 2, "x"), + TraversalUtil.parsePredicate("P.within(1.00, 2, \"x\")")); + Assert.assertEquals(P.eq(7), TraversalUtil.parsePredicate("P.eq(7)")); + } } diff --git a/hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/business/GraphStoreIterator.java b/hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/business/GraphStoreIterator.java index b4882fc8b0..4d1d1e991e 100644 --- a/hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/business/GraphStoreIterator.java +++ b/hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/business/GraphStoreIterator.java @@ -19,6 +19,7 @@ import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.HashSet; import java.util.Iterator; @@ -257,8 +258,10 @@ private

> List buildProperties( .setValueDouble((Double) v); break; case DECIMAL: + // A LIST/SET value is a collection of decimals: every + // member in plain form, as a JSON array of strings variant.setType(VariantType.VT_STRING) - .setValueString(((BigDecimal) v).toPlainString()); + .setValueString(decimalString(v)); break; case OBJECT: case UNKNOWN: @@ -278,6 +281,20 @@ private

> List buildProperties( return props; } + private static String decimalString(Object value) { + if (!(value instanceof Collection)) { + return ((BigDecimal) value).toPlainString(); + } + StringBuilder sb = new StringBuilder("["); + for (Object member : (Collection) value) { + if (sb.length() > 1) { + sb.append(','); + } + sb.append('"').append(((BigDecimal) member).toPlainString()).append('"'); + } + return sb.append(']').toString(); + } + private void buildId(Builder variant, Id id) { switch (id.type()) { case STRING: From 10e6e2779906bb4e7189d340f73eced62894af6c Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Fri, 2 Oct 2026 10:46:13 +0000 Subject: [PATCH 09/11] fix(core): userdata reloads with the default reader, strict DECIMAL defaults on create, OBJECT contents keep their number types, CONTAINS by value - BinarySerializer/TextSerializer read userdata with JsonUtil.fromJson again: a DECIMAL default is stored as a string and normalised on the key, every other fraction stays a Double across restarts (PropertyKeyCoreTest round trip through the backend cache). - PropertyKey: the bulk userdata path (builder create/append) rejects an invalid or out-of-bounds DECIMAL default; the per-entry path (storage reload) stays lenient. - PropertiesDeserializer reads exactly only the values of the properties object and the members of a top-level array; nested objects and arrays (OBJECT contents) keep Jackson's number types. - Condition.RelationType.CONTAINS compares decimals by value, both copies. - @SuppressWarnings back on parseProperties, Javadoc on normalizeDefaultValue. Co-Authored-By: Claude Opus 4.8 --- .../java/org/apache/hugegraph/api/API.java | 2 +- .../api/graph/PropertiesDeserializer.java | 30 ++++++++---- .../hugegraph/backend/query/Condition.java | 2 +- .../backend/serializer/BinarySerializer.java | 4 +- .../backend/serializer/TextSerializer.java | 2 +- .../apache/hugegraph/schema/PropertyKey.java | 47 +++++++++++++------ .../apache/hugegraph/api/VertexApiTest.java | 26 ++++++++++ .../hugegraph/core/PropertyKeyCoreTest.java | 31 ++++++++++++ .../api/graph/PropertiesDeserializerTest.java | 20 ++++++-- .../hugegraph/unit/core/ConditionTest.java | 8 ++++ .../unit/core/PropertyKeyUserdataTest.java | 18 ++++++- .../unit/serializer/BinarySerializerTest.java | 38 +++++++++++++++ .../unit/serializer/TextSerializerTest.java | 38 +++++++++++++++ .../org/apache/hugegraph/query/Condition.java | 2 +- .../apache/hugegraph/query/ConditionTest.java | 4 ++ 15 files changed, 239 insertions(+), 33 deletions(-) diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java index c1ee404af8..ad3bb49e3f 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java @@ -224,7 +224,6 @@ protected static void checkUpdatingBody(Collection bodies) } } - @SuppressWarnings("unchecked") /** * Convert each filter value to the runtime type of its property key the * way the traversal does (TraversalUtil.validPropertyValue, which knows @@ -251,6 +250,7 @@ protected static void normalizeProperties(HugeGraph g, Map props } } + @SuppressWarnings("unchecked") protected static Map parseProperties(String properties) { if (properties == null || properties.isEmpty()) { return ImmutableMap.of(); diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java index 0a188caace..1c3c73e974 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java @@ -51,44 +51,56 @@ public Map deserialize(JsonParser parser, throw JsonMappingException.from(parser, "Expected an object for 'properties', but got " + token); } - return readObject(parser); + return readObject(parser, true); } - private static Map readObject(JsonParser parser) + /** + * `exact` holds for the values of the properties object itself and the + * elements of a top-level array (a DECIMAL value or a DECIMAL LIST/SET); + * anything nested deeper is the content of an OBJECT property and keeps + * Jackson's default number types. + */ + private static Map readObject(JsonParser parser, boolean exact) throws IOException { Map object = new LinkedHashMap<>(); while (parser.nextToken() != JsonToken.END_OBJECT) { String name = parser.currentName(); parser.nextToken(); - object.put(name, readValue(parser)); + object.put(name, readValue(parser, exact)); } return object; } - private static List readArray(JsonParser parser) + private static List readArray(JsonParser parser, boolean exact) throws IOException { List array = new ArrayList<>(); while (parser.nextToken() != JsonToken.END_ARRAY) { - array.add(readValue(parser)); + array.add(readValue(parser, exact)); } return array; } /** One value at the parser's current token: exact fractions, nested objects and arrays. */ public static Object readValue(JsonParser parser) throws IOException { + return readValue(parser, true); + } + + private static Object readValue(JsonParser parser, boolean exact) throws IOException { JsonToken token = parser.currentToken(); switch (token) { case START_OBJECT: - return readObject(parser); + return readObject(parser, false); case START_ARRAY: - return readArray(parser); + // a top-level array is a LIST/SET value: its members are exact; + // an array inside an array or an object is OBJECT content + return readArray(parser, exact && !parser.getParsingContext().getParent().inArray()); 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(); + // Exact for a property value: the literal's digits, not the nearest double + return exact ? parser.getDecimalValue() : (Object) parser.getDoubleValue(); case VALUE_TRUE: return Boolean.TRUE; case VALUE_FALSE: diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/Condition.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/Condition.java index ca4b7196ed..6702153712 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/Condition.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/query/Condition.java @@ -116,7 +116,7 @@ public enum RelationType implements BiPredicate { CONTAINS("contains", Collection.class, null, (v1, v2) -> { assert v2 != null; - return v1 != null && ((Collection) v1).contains(v2); + return v1 != null && collectionContains((Collection) v1, v2); }), CONTAINS_VALUE("containsv", Map.class, null, (v1, v2) -> { diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BinarySerializer.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BinarySerializer.java index 92038e47ba..0bb07760a5 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BinarySerializer.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/BinarySerializer.java @@ -1237,8 +1237,8 @@ private void readUserdata(SchemaElement schema) { byte[] userdataBytes = column(HugeKeys.USER_DATA); String userdataStr = StringEncoding.decode(userdataBytes); @SuppressWarnings("unchecked") - Map userdata = JsonUtil.fromJsonExact(userdataStr, - Map.class); + Map userdata = JsonUtil.fromJson(userdataStr, + Map.class); for (Map.Entry e : userdata.entrySet()) { schema.userdata(e.getKey(), e.getValue()); } diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/TextSerializer.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/TextSerializer.java index 9276090fb9..2d5cb81ec1 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/TextSerializer.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/serializer/TextSerializer.java @@ -911,7 +911,7 @@ private static void readUserdata(SchemaElement schema, // Parse all user data of a schema element String userdataStr = entry.column(HugeKeys.USER_DATA); @SuppressWarnings("unchecked") - Map userdata = JsonUtil.fromJsonExact(userdataStr, + Map userdata = JsonUtil.fromJson(userdataStr, Map.class); for (Map.Entry e : userdata.entrySet()) { schema.userdata(e.getKey(), e.getValue()); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java index cf7b92f2b5..ee69c87e9c 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/PropertyKey.java @@ -120,16 +120,6 @@ public void defineDefaultValue(Object value) { this.userdata().put(Userdata.DEFAULT_VALUE, this.normalizeDefaultValue(value)); } - /** - * A DECIMAL key keeps its default value in userdata as the exact - * BigDecimal (a JSON fraction arrives as BigDecimal, a string is parsed), - * both when the key is defined through the API and when it is read back - * from the backend, so the value serializes the same way on every path. - * Every other data type keeps the raw value the user sent, as before: - * {@link #defaultValue()} converts it lazily when it is applied; only a - * BigDecimal (which exists solely because the API reads the default - * exactly) becomes the Double the default parser produced on master. - */ /** A BigDecimal from the exact parser as the Double the default parser gave. */ private static Object undoExact(Object value) { if (value instanceof BigDecimal) { @@ -145,23 +135,51 @@ private static Object undoExact(Object value) { return value; } + /** + * One entry, as the backend serializers add them when a stored key is + * read back: an already stored DECIMAL default that does not convert + * keeps loading (the error surfaces when the default is applied). + */ @Override public void userdata(String key, Object value) { if (Userdata.DEFAULT_VALUE.equals(key)) { - value = this.normalizeDefaultValue(value); + value = this.normalizeDefaultValue(value, true); } super.userdata(key, value); } + /** + * The userdata of a create or append through the builder: an invalid or + * out-of-bounds DECIMAL default is rejected here, not at the first vertex + * that would have used it. + */ @Override public void userdata(Userdata userdata) { E.checkArgumentNotNull(userdata, "userdata"); for (Map.Entry e : userdata.entrySet()) { - this.userdata(e.getKey(), e.getValue()); + Object value = e.getValue(); + if (Userdata.DEFAULT_VALUE.equals(e.getKey())) { + value = this.normalizeDefaultValue(value, false); + } + super.userdata(e.getKey(), value); } } + /** + * A DECIMAL key keeps its default value in userdata as the exact + * BigDecimal (a JSON fraction arrives as BigDecimal, a string is parsed), + * both when the key is defined through the API and when it is read back + * from the backend, so the value serializes the same way on every path. + * Every other data type keeps the raw value the user sent, as before: + * {@link #defaultValue()} converts it lazily when it is applied; only a + * BigDecimal (which exists solely because the API reads the default + * exactly) becomes the Double the default parser produced on master. + */ private Object normalizeDefaultValue(Object value) { + return this.normalizeDefaultValue(value, false); + } + + private Object normalizeDefaultValue(Object value, boolean lenient) { if (value == null) { return value; } @@ -173,12 +191,13 @@ private Object normalizeDefaultValue(Object value) { !(value instanceof Set)) { raw = new LinkedHashSet<>((Collection) value); } + if (!lenient) { + return this.validValueOrThrow(raw); + } try { Object valid = this.validValue(raw); return valid != null ? valid : value; } catch (RuntimeException e) { - // An already stored default that does not convert keeps loading; - // the error surfaces when the default is applied, as before return value; } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java index 0489b21eee..e070cc48bc 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/VertexApiTest.java @@ -325,6 +325,32 @@ public void testDecimalJsonNumberLiteralIsExact() throws IOException { Assert.assertContains("\"~default_value\":1.5", content); // custom metadata keeps its type: a JSON number, not a string Assert.assertContains("\"rate\":0.85", content); + // an OBJECT property keeps the numbers inside it as numbers + createAndAssert(URL_PREFIX + "/schema/propertykeys", + "{" + + "\"name\": \"meta\"," + + "\"data_type\": \"OBJECT\"," + + "\"cardinality\": \"SINGLE\"," + + "\"check_exist\": false," + + "\"properties\":[]" + + "}", 202); + createAndAssert(URL_PREFIX + "/schema/vertexlabels", + "{" + + "\"primary_keys\":[\"name\"]," + + "\"id_strategy\": \"PRIMARY_KEY\"," + + "\"name\": \"metas\"," + + "\"properties\":[\"name\", \"meta\"]," + + "\"check_exist\": false," + + "\"nullable_keys\":[\"meta\"]" + + "}"); + r = client().post(PATH, "{\"label\":\"metas\",\"properties\":{\"name\":\"m1\"," + + "\"meta\":{\"ratio\":0.25,\"n\":3}}}"); + content = assertResponseStatus(201, r); + Assert.assertContains("\"ratio\":0.25", content); + r = client().get(PATH, String.format("\"%s\"", parseId(content))); + content = assertResponseStatus(200, r); + Assert.assertContains("\"ratio\":0.25", content); + // a default of another type keeps the form the user sent (a DATE // default is converted only when it is applied, as on master) createAndAssert(URL_PREFIX + "/schema/propertykeys", diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java index c509e74fe6..384ff376b5 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java @@ -837,4 +837,35 @@ public void testAddPropertyKeyWithDecimalType() { new BigDecimal("2.5")), amounts.validValue(ImmutableList.of("1", "2.5"))); } + + /** + * Userdata read back from the backend keeps its types: a fractional + * custom entry stays a Double and a DECIMAL default stays the exact + * BigDecimal, on every schema element. + */ + @Test + public void testUserdataTypesSurviveABackendReload() { + SchemaManager schema = graph().schema(); + schema.propertyKey("fee").asDecimal() + .userdata(Userdata.DEFAULT_VALUE, new BigDecimal("0.1234567890123456789")) + .userdata("rate", 0.85d) + .create(); + schema.propertyKey("name").asText().create(); + schema.vertexLabel("account").properties("name", "fee").primaryKeys("name") + .nullableKeys("fee").userdata("rate", 0.85d).create(); + + org.apache.hugegraph.backend.cache.CacheManager.instance().clearCache(); + + PropertyKey fee = graph().propertyKey("fee"); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), + fee.userdata().get(Userdata.DEFAULT_VALUE)); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), fee.defaultValue()); + Assert.assertEquals(0.85d, fee.userdata().get("rate")); + Assert.assertEquals(0.85d, graph().vertexLabel("account").userdata().get("rate")); + // and after a write that re-persists the element + schema.vertexLabel("account").userdata("note", "x").append(); + org.apache.hugegraph.backend.cache.CacheManager.instance().clearCache(); + Assert.assertEquals(0.85d, graph().vertexLabel("account").userdata().get("rate")); + Assert.assertEquals("x", graph().vertexLabel("account").userdata().get("note")); + } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java index f79cd61459..2e246ff480 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java @@ -70,14 +70,28 @@ public void testFractionsAreExactInPropertiesOnly() throws Exception { List tags = (List) props.get("tags"); Assert.assertEquals(new BigDecimal("1.5"), tags.get(0)); Assert.assertEquals("x", tags.get(1)); - Assert.assertEquals(new BigDecimal("2.25"), ((List) tags.get(2)).get(0)); - Assert.assertEquals(new BigDecimal("0.1"), - ((Map) props.get("nested")).get("w")); + // content of an OBJECT value keeps Jackson's types: an array in an + // array, and a fraction inside a nested object + Assert.assertEquals(2.25d, ((List) tags.get(2)).get(0)); + Assert.assertEquals(0.1d, ((Map) props.get("nested")).get("w")); // key order is kept Assert.assertEquals("amount", props.keySet().iterator().next()); // everything outside "properties" keeps Jackson's default types Assert.assertEquals(0.85d, body.options.get("alpha")); + + // an OBJECT property with nested numbers round-trips them as numbers + body = MAPPER.readValue( + "{\"label\":\"a\",\"properties\":{\"meta\":{\"ratio\":0.25,\"n\":3," + + "\"inner\":{\"p\":1.5},\"list\":[0.5]},\"amounts\":[1.5,2.50]}}", Body.class); + Map meta = (Map) body.properties.get("meta"); + Assert.assertEquals(0.25d, meta.get("ratio")); + Assert.assertEquals(3, meta.get("n")); + Assert.assertEquals(1.5d, ((Map) meta.get("inner")).get("p")); + Assert.assertEquals(0.5d, ((List) meta.get("list")).get(0)); + Assert.assertEquals(new BigDecimal("2.50"), ((List) body.properties.get("amounts")).get(1)); + Assert.assertEquals("{\"ratio\":0.25,\"n\":3,\"inner\":{\"p\":1.5},\"list\":[0.5]}", + org.apache.hugegraph.util.JsonUtil.toJson(meta)); } public static class KeyBody { diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/ConditionTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/ConditionTest.java index 93d9543372..62ccf60190 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/ConditionTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/ConditionTest.java @@ -416,6 +416,14 @@ public void testConditionInMatchesNumbersByValue() { Assert.assertFalse(n.test(1L)); Assert.assertTrue(n.test(new BigDecimal("1.1"))); Assert.assertTrue(n.test("1.0")); + + // CONTAINS on a LIST/SET value: a stored 1.00 contains 1.0 + Condition c2 = new SyspropRelation(HugeKeys.ID, RelationType.CONTAINS, new BigDecimal("1.0")); + Assert.assertTrue(c2.test(ImmutableList.of(new BigDecimal("1.00"), new BigDecimal("2")))); + Assert.assertFalse(c2.test(ImmutableList.of(new BigDecimal("1.01")))); + Condition c3 = new SyspropRelation(HugeKeys.ID, RelationType.CONTAINS, 1); + Assert.assertTrue(c3.test(ImmutableList.of(1, 2))); + Assert.assertFalse(c3.test(ImmutableList.of(1.0d))); } @Test diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/PropertyKeyUserdataTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/PropertyKeyUserdataTest.java index 6c4790902b..e78356e413 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/PropertyKeyUserdataTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/PropertyKeyUserdataTest.java @@ -54,9 +54,25 @@ public void testDecimalDefaultIsExact() { list.userdata(Userdata.DEFAULT_VALUE, Arrays.asList(new BigDecimal("1.5"), 2)); Assert.assertEquals(Arrays.asList(new BigDecimal("1.5"), new BigDecimal("2")), list.userdata().get(Userdata.DEFAULT_VALUE)); - // an unconvertible default is kept raw instead of failing the load + // a stored default that does not convert keeps loading (per-entry path)... pkey.userdata(Userdata.DEFAULT_VALUE, "not-a-number"); Assert.assertEquals("not-a-number", pkey.userdata().get(Userdata.DEFAULT_VALUE)); + // ...but a create/append through the builder (bulk path) rejects it + Userdata bad = new Userdata(); + bad.put(Userdata.DEFAULT_VALUE, "not-a-number"); + Assert.assertThrows(IllegalArgumentException.class, () -> { + key(DataType.DECIMAL, Cardinality.SINGLE).userdata(bad); + }); + Userdata huge = new Userdata(); + huge.put(Userdata.DEFAULT_VALUE, new BigDecimal("1E+999999999")); + Assert.assertThrows(IllegalArgumentException.class, () -> { + key(DataType.DECIMAL, Cardinality.SINGLE).userdata(huge); + }); + Userdata good = new Userdata(); + good.put(Userdata.DEFAULT_VALUE, "0.5"); + PropertyKey ok = key(DataType.DECIMAL, Cardinality.SINGLE); + ok.userdata(good); + Assert.assertEquals(new BigDecimal("0.5"), ok.userdata().get(Userdata.DEFAULT_VALUE)); } @Test diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/BinarySerializerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/BinarySerializerTest.java index 02d64bcc78..66743fe12c 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/BinarySerializerTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/BinarySerializerTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.unit.serializer; +import java.math.BigDecimal; import java.util.Arrays; import java.util.Date; import java.util.Set; @@ -27,12 +28,14 @@ import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.schema.PropertyKey; import org.apache.hugegraph.schema.Userdata; +import org.apache.hugegraph.schema.VertexLabel; import org.apache.hugegraph.structure.HugeEdge; import org.apache.hugegraph.structure.HugeVertex; import org.apache.hugegraph.testutil.Assert; import org.apache.hugegraph.testutil.Whitebox; import org.apache.hugegraph.type.define.Cardinality; import org.apache.hugegraph.type.define.DataType; +import org.apache.hugegraph.type.define.IdStrategy; import org.apache.hugegraph.unit.BaseUnitTest; import org.apache.hugegraph.unit.FakeObjects; import org.apache.hugegraph.util.DateUtil; @@ -206,4 +209,39 @@ public void testEdgeForPartition() { Assert.assertEquals(edge2, edge); assertCollectionEquals(edge2.getProperties(), edge.getProperties()); } + + /** + * Userdata read back from the backend keeps its types: a fractional + * custom entry stays a Double (on every schema element) and a DECIMAL + * default stays the exact BigDecimal. + */ + @Test + public void testUserdataTypesSurviveAReload() { + HugeConfig config = FakeObjects.newConfig(); + BinarySerializer ser = new BinarySerializer(config); + FakeObjects objects = new FakeObjects(); + + PropertyKey fee = objects.newPropertyKey(IdGenerator.of(7L), "fee", + DataType.DECIMAL, Cardinality.SINGLE); + Userdata userdata = new Userdata(); + userdata.put(Userdata.DEFAULT_VALUE, new BigDecimal("0.1234567890123456789")); + userdata.put("rate", 0.85d); + fee.userdata(userdata); + PropertyKey reloadedKey = ser.readPropertyKey(objects.graph(), ser.writePropertyKey(fee)); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), + reloadedKey.userdata().get(Userdata.DEFAULT_VALUE)); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), reloadedKey.defaultValue()); + Assert.assertEquals(0.85d, reloadedKey.userdata().get("rate")); + // a second write of the reloaded key keeps the types too + PropertyKey again = ser.readPropertyKey(objects.graph(), ser.writePropertyKey(reloadedKey)); + Assert.assertEquals(0.85d, again.userdata().get("rate")); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), again.defaultValue()); + + VertexLabel person = objects.newVertexLabel(IdGenerator.of(2L), "person", + IdStrategy.PRIMARY_KEY, + IdGenerator.of(7L)); + person.userdata("rate", 0.85d); + VertexLabel reloadedLabel = ser.readVertexLabel(objects.graph(), ser.writeVertexLabel(person)); + Assert.assertEquals(0.85d, reloadedLabel.userdata().get("rate")); + } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/TextSerializerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/TextSerializerTest.java index 4d55a9b046..44a153c582 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/TextSerializerTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/TextSerializerTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.unit.serializer; +import java.math.BigDecimal; import java.util.Arrays; import java.util.Date; import java.util.Set; @@ -27,9 +28,11 @@ import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.schema.PropertyKey; import org.apache.hugegraph.schema.Userdata; +import org.apache.hugegraph.schema.VertexLabel; import org.apache.hugegraph.testutil.Assert; import org.apache.hugegraph.type.define.Cardinality; import org.apache.hugegraph.type.define.DataType; +import org.apache.hugegraph.type.define.IdStrategy; import org.apache.hugegraph.unit.BaseUnitTest; import org.apache.hugegraph.unit.FakeObjects; import org.apache.hugegraph.util.DateUtil; @@ -104,4 +107,39 @@ public void testPropertyKeySetDefaultValueRoundTripsAsDate() { Assert.assertEquals("duplicates must be collapsed", 1, values.size()); Assert.assertTrue(values.contains(expected)); } + + /** + * Userdata read back from the backend keeps its types: a fractional + * custom entry stays a Double (on every schema element) and a DECIMAL + * default stays the exact BigDecimal. + */ + @Test + public void testUserdataTypesSurviveAReload() { + HugeConfig config = FakeObjects.newConfig(); + TextSerializer ser = new TextSerializer(config); + FakeObjects objects = new FakeObjects(); + + PropertyKey fee = objects.newPropertyKey(IdGenerator.of(7L), "fee", + DataType.DECIMAL, Cardinality.SINGLE); + Userdata userdata = new Userdata(); + userdata.put(Userdata.DEFAULT_VALUE, new BigDecimal("0.1234567890123456789")); + userdata.put("rate", 0.85d); + fee.userdata(userdata); + PropertyKey reloadedKey = ser.readPropertyKey(objects.graph(), ser.writePropertyKey(fee)); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), + reloadedKey.userdata().get(Userdata.DEFAULT_VALUE)); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), reloadedKey.defaultValue()); + Assert.assertEquals(0.85d, reloadedKey.userdata().get("rate")); + // a second write of the reloaded key keeps the types too + PropertyKey again = ser.readPropertyKey(objects.graph(), ser.writePropertyKey(reloadedKey)); + Assert.assertEquals(0.85d, again.userdata().get("rate")); + Assert.assertEquals(new BigDecimal("0.1234567890123456789"), again.defaultValue()); + + VertexLabel person = objects.newVertexLabel(IdGenerator.of(2L), "person", + IdStrategy.PRIMARY_KEY, + IdGenerator.of(7L)); + person.userdata("rate", 0.85d); + VertexLabel reloadedLabel = ser.readVertexLabel(objects.graph(), ser.writeVertexLabel(person)); + Assert.assertEquals(0.85d, reloadedLabel.userdata().get("rate")); + } } diff --git a/hugegraph-struct/src/main/java/org/apache/hugegraph/query/Condition.java b/hugegraph-struct/src/main/java/org/apache/hugegraph/query/Condition.java index 5fa0b71c0b..0be54aa495 100644 --- a/hugegraph-struct/src/main/java/org/apache/hugegraph/query/Condition.java +++ b/hugegraph-struct/src/main/java/org/apache/hugegraph/query/Condition.java @@ -302,7 +302,7 @@ public enum RelationType implements BiPredicate { CONTAINS("contains", Collection.class, null, (v1, v2) -> { assert v2 != null; - return v1 != null && ((Collection) v1).contains(v2); + return v1 != null && collectionContains((Collection) v1, v2); }), CONTAINS_VALUE("containsv", Map.class, null, (v1, v2) -> { diff --git a/hugegraph-struct/src/test/java/org/apache/hugegraph/query/ConditionTest.java b/hugegraph-struct/src/test/java/org/apache/hugegraph/query/ConditionTest.java index 8aa3dbd31d..4cf30ed982 100644 --- a/hugegraph-struct/src/test/java/org/apache/hugegraph/query/ConditionTest.java +++ b/hugegraph-struct/src/test/java/org/apache/hugegraph/query/ConditionTest.java @@ -40,6 +40,10 @@ public void testConditionInMatchesNumbersByValue() { Arrays.asList(new BigDecimal("1.00"))); Assert.assertFalse(n.test(new BigDecimal("1.0"))); Assert.assertTrue(n.test(new BigDecimal("1.1"))); + Condition c2 = new Condition.SyspropRelation(HugeKeys.ID, Condition.RelationType.CONTAINS, + new BigDecimal("1.0")); + Assert.assertTrue(c2.test(Arrays.asList(new BigDecimal("1.00"), new BigDecimal("2")))); + Assert.assertFalse(c2.test(Arrays.asList(new BigDecimal("1.01")))); } @Test From 371275c02131e6b6ef94a9276e461802c8442921 Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Fri, 2 Oct 2026 11:31:45 +0000 Subject: [PATCH 10/11] fix(core): let the provider serialize property values in GraphSON provider.findValueSerializer() returns an uncontextualized Map serializer (no key serializer), so any OBJECT property holding a map failed with "Failed to serialize property" on every vertex response. Use defaultSerializeField() and keep the cause on the HugeException. --- .../hugegraph/io/HugeGraphSONModule.java | 15 +++------ .../serializer/HugeGraphSONModuleTest.java | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java index a2cc8b387d..849ef1003c 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/io/HugeGraphSONModule.java @@ -68,7 +68,6 @@ import org.apache.tinkerpop.shaded.jackson.core.JsonToken; import org.apache.tinkerpop.shaded.jackson.core.type.WritableTypeId; import org.apache.tinkerpop.shaded.jackson.databind.DeserializationContext; -import org.apache.tinkerpop.shaded.jackson.databind.JsonSerializer; import org.apache.tinkerpop.shaded.jackson.databind.SerializerProvider; import org.apache.tinkerpop.shaded.jackson.databind.deser.std.DateDeserializers.DateDeserializer; import org.apache.tinkerpop.shaded.jackson.databind.deser.std.StdDeserializer; @@ -761,18 +760,14 @@ public void writePropertiesField(Collection> properties, String key = property.key(); Object val = property.value(); try { - generator.writeFieldName(key); - if (val != null) { - JsonSerializer serializer = - provider.findValueSerializer(val.getClass()); - serializer.serialize(val, generator, provider); - } else { - generator.writeNull(); - } + // The provider contextualizes the serializer: a bare + // findValueSerializer() returns a Map serializer without + // its key serializer, so an OBJECT map value failed here + provider.defaultSerializeField(key, val, generator); } catch (IOException e) { throw new HugeException( "Failed to serialize property(%s: %s) " + - "for vertex '%s'", key, val, property.element()); + "for vertex '%s'", e, key, val, property.element()); } } // End write properties diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/HugeGraphSONModuleTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/HugeGraphSONModuleTest.java index ea01f3f432..a19b3a770b 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/HugeGraphSONModuleTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/serializer/HugeGraphSONModuleTest.java @@ -22,9 +22,18 @@ import java.util.Map; import java.util.UUID; +import org.apache.hugegraph.backend.id.IdGenerator; import org.apache.hugegraph.io.HugeGraphIoRegistry; +import org.apache.hugegraph.schema.PropertyKey; +import org.apache.hugegraph.schema.VertexLabel; +import org.apache.hugegraph.structure.HugeVertex; import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.type.define.Cardinality; +import org.apache.hugegraph.type.define.DataType; +import org.apache.hugegraph.type.define.IdStrategy; import org.apache.hugegraph.unit.BaseUnitTest; +import org.apache.hugegraph.unit.FakeObjects; +import org.apache.hugegraph.util.JsonUtil; import org.apache.tinkerpop.gremlin.driver.message.ResponseMessage; import org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerV1d0; import org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerV2d0; @@ -67,6 +76,30 @@ private static Object firstResult(ResponseMessage message) { return data.get(0); } + /** + * An OBJECT property holding a map: the response serializer must let the + * provider contextualize the Map serializer (a bare lookup has no key + * serializer and failed on every map value), and the numbers inside keep + * their JSON number types. + */ + @Test + public void testObjectMapPropertyInVertexJson() { + FakeObjects objects = new FakeObjects(); + PropertyKey name = objects.newPropertyKey(IdGenerator.of(1), "name"); + PropertyKey meta = objects.newPropertyKey(IdGenerator.of(2), "meta", + DataType.OBJECT, Cardinality.SINGLE); + VertexLabel label = objects.newVertexLabel(IdGenerator.of(1), "metas", + IdStrategy.CUSTOMIZE_NUMBER, + name.id(), meta.id()); + HugeVertex vertex = new HugeVertex(objects.graph(), IdGenerator.of(1L), label); + vertex.addProperty(name, "m1"); + vertex.addProperty(meta, ImmutableMap.of("ratio", 0.25, "n", 3)); + + String json = JsonUtil.toJson(vertex); + Assert.assertContains("\"meta\":{\"ratio\":0.25,\"n\":3}", json); + Assert.assertContains("\"name\":\"m1\"", json); + } + @Test public void testBigDecimalThroughGraphSONV1() throws Exception { GraphSONMessageSerializerV1d0 serializer = From 2034d24092a2abc184d2e1f49ec8e810de37145e Mon Sep 17 00:00:00 2001 From: Sebastian Gruza Date: Fri, 2 Oct 2026 19:25:00 +0000 Subject: [PATCH 11/11] fix(api): read the properties filter by the request-body rule, drop the core test that cleared the shared cache - API.parseProperties goes through PropertiesDeserializer.parse: exact fractions for the filter values and top-level array members, Jackson's number types inside an OBJECT value, so an OBJECT filter equals the stored map (EdgeApiTest adjacency regression on meta={ratio:0.25}) - PropertyKeyCoreTest.testUserdataTypesSurviveABackendReload removed: its CacheManager.clearCache() left schema behind for the shared suite; the reload is covered by the serializer round-trip tests --- .../java/org/apache/hugegraph/api/API.java | 8 ++-- .../api/graph/PropertiesDeserializer.java | 23 ++++++++++ .../org/apache/hugegraph/api/EdgeApiTest.java | 43 +++++++++++++++++++ .../hugegraph/core/PropertyKeyCoreTest.java | 31 ------------- .../api/graph/PropertiesDeserializerTest.java | 20 +++++++++ 5 files changed, 91 insertions(+), 34 deletions(-) diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java index ad3bb49e3f..a89d9ec42c 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java @@ -26,6 +26,7 @@ import org.apache.commons.lang.mutable.MutableLong; import org.apache.hugegraph.HugeException; import org.apache.hugegraph.HugeGraph; +import org.apache.hugegraph.api.graph.PropertiesDeserializer; import org.apache.hugegraph.core.GraphManager; import org.apache.hugegraph.define.Checkable; import org.apache.hugegraph.exception.NotFoundException; @@ -37,7 +38,6 @@ 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; @@ -258,8 +258,10 @@ protected static Map parseProperties(String properties) { Map props = null; try { - // Exact fractions: a DECIMAL filter keeps every digit - props = JsonUtil.fromJsonExact(properties, Map.class); + // The request-body rule: exact fractions for the filter values + // and top-level array members (a DECIMAL filter keeps every + // digit), Jackson's number types inside an OBJECT value + props = PropertiesDeserializer.parse(properties); } catch (Exception ignored) { // ignore } diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java index 1c3c73e974..bbfa4387fe 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/graph/PropertiesDeserializer.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; +import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; @@ -39,6 +40,8 @@ */ public class PropertiesDeserializer extends JsonDeserializer> { + private static final JsonFactory FACTORY = new JsonFactory(); + @Override public Map deserialize(JsonParser parser, DeserializationContext ctxt) @@ -54,6 +57,26 @@ public Map deserialize(JsonParser parser, return readObject(parser, true); } + /** + * The properties filter of the list APIs, read by the same rule as a + * request body: exact fractions for the values of the object and the + * members of a top-level array, Jackson's number types inside an OBJECT + * value, so a filter on an OBJECT key equals the stored map. + */ + public static Map parse(String json) throws IOException { + try (JsonParser parser = FACTORY.createParser(json)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + throw JsonMappingException.from(parser, + "Expected an object for 'properties', but got " + parser.currentToken()); + } + Map properties = readObject(parser, true); + if (parser.nextToken() != null) { + throw JsonMappingException.from(parser, "Trailing content after 'properties'"); + } + return properties; + } + } + /** * `exact` holds for the values of the properties object itself and the * elements of a top-level array (a DECIMAL value or a DECIMAL LIST/SET); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/EdgeApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/EdgeApiTest.java index a7a9eab375..e878c3b419 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/EdgeApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/EdgeApiTest.java @@ -18,6 +18,8 @@ package org.apache.hugegraph.api; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import org.apache.hugegraph.testutil.Assert; import org.junit.Before; @@ -182,6 +184,47 @@ public void testList() throws IOException { assertResponseStatus(200, r); } + /** + * An adjacency query filtered on an OBJECT key: the filter map is read + * by the request-body rule (numbers inside an OBJECT value keep their + * Jackson types), so it equals the stored map and the edge is found. + */ + @Test + public void testObjectMapFilterOnAdjacency() throws IOException { + String schema = "/graphspaces/DEFAULT/graphs/hugegraph/schema/"; + createAndAssert(schema + "propertykeys", + "{\"name\": \"meta\", \"data_type\": \"OBJECT\"," + + "\"cardinality\": \"SINGLE\", \"check_exist\": false," + + "\"properties\":[]}", 202); + createAndAssert(schema + "edgelabels", + "{\"name\": \"rated\", \"source_label\": \"person\"," + + "\"target_label\": \"software\", \"frequency\": \"SINGLE\"," + + "\"properties\":[\"meta\"], \"nullable_keys\":[\"meta\"]," + + "\"check_exist\": false}"); + String outVId = getVertexId("person", "name", "peter"); + String inVId = getVertexId("software", "name", "lop"); + String edge = String.format("{\"label\": \"rated\", \"outVLabel\": \"person\"," + + "\"inVLabel\": \"software\", \"outV\": \"%s\"," + + "\"inV\": \"%s\", \"properties\":{" + + "\"meta\": {\"ratio\": 0.25, \"n\": 3}}}", + outVId, inVId); + String content = assertResponseStatus(201, client().post(PATH, edge)); + Assert.assertContains("\"ratio\":0.25", content); + + Response r = client().get(PATH, ImmutableMap.of( + "vertex_id", id2Json(outVId), "direction", "OUT", "label", "rated", + "properties", URLEncoder.encode("{\"meta\":{\"ratio\":0.25,\"n\":3}}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertContains("\"ratio\":0.25", content); + r = client().get(PATH, ImmutableMap.of( + "vertex_id", id2Json(outVId), "direction", "OUT", "label", "rated", + "properties", URLEncoder.encode("{\"meta\":{\"ratio\":0.5,\"n\":3}}", + StandardCharsets.UTF_8))); + content = assertResponseStatus(200, r); + Assert.assertEquals("{\"edges\":[]}", content); + } + @Test public void testDelete() throws IOException { String outVId = getVertexId("person", "name", "peter"); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java index 384ff376b5..c509e74fe6 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/PropertyKeyCoreTest.java @@ -837,35 +837,4 @@ public void testAddPropertyKeyWithDecimalType() { new BigDecimal("2.5")), amounts.validValue(ImmutableList.of("1", "2.5"))); } - - /** - * Userdata read back from the backend keeps its types: a fractional - * custom entry stays a Double and a DECIMAL default stays the exact - * BigDecimal, on every schema element. - */ - @Test - public void testUserdataTypesSurviveABackendReload() { - SchemaManager schema = graph().schema(); - schema.propertyKey("fee").asDecimal() - .userdata(Userdata.DEFAULT_VALUE, new BigDecimal("0.1234567890123456789")) - .userdata("rate", 0.85d) - .create(); - schema.propertyKey("name").asText().create(); - schema.vertexLabel("account").properties("name", "fee").primaryKeys("name") - .nullableKeys("fee").userdata("rate", 0.85d).create(); - - org.apache.hugegraph.backend.cache.CacheManager.instance().clearCache(); - - PropertyKey fee = graph().propertyKey("fee"); - Assert.assertEquals(new BigDecimal("0.1234567890123456789"), - fee.userdata().get(Userdata.DEFAULT_VALUE)); - Assert.assertEquals(new BigDecimal("0.1234567890123456789"), fee.defaultValue()); - Assert.assertEquals(0.85d, fee.userdata().get("rate")); - Assert.assertEquals(0.85d, graph().vertexLabel("account").userdata().get("rate")); - // and after a write that re-persists the element - schema.vertexLabel("account").userdata("note", "x").append(); - org.apache.hugegraph.backend.cache.CacheManager.instance().clearCache(); - Assert.assertEquals(0.85d, graph().vertexLabel("account").userdata().get("rate")); - Assert.assertEquals("x", graph().vertexLabel("account").userdata().get("note")); - } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java index 2e246ff480..3600151a7a 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/graph/PropertiesDeserializerTest.java @@ -134,4 +134,24 @@ public void testNullAndNonObject() throws Exception { MAPPER.readValue("{\"label\":\"x\",\"properties\":[1]}", Body.class); }); } + + /** The list-API filter is read by the same rule as a request body. */ + @Test + public void testFilterParsesLikeABody() throws Exception { + Map filter = PropertiesDeserializer.parse( + "{\"amount\":12345678901234567890.123456789012345678," + + "\"amounts\":[1.5,2],\"meta\":{\"ratio\":0.25,\"n\":3}}"); + Assert.assertEquals(new BigDecimal("12345678901234567890.123456789012345678"), + filter.get("amount")); + Assert.assertEquals(new BigDecimal("1.5"), ((List) filter.get("amounts")).get(0)); + Map meta = (Map) filter.get("meta"); + Assert.assertEquals(0.25d, meta.get("ratio")); + Assert.assertEquals(3, meta.get("n")); + Assert.assertThrows(com.fasterxml.jackson.databind.JsonMappingException.class, () -> { + PropertiesDeserializer.parse("[1]"); + }); + Assert.assertThrows(com.fasterxml.jackson.core.JsonProcessingException.class, () -> { + PropertiesDeserializer.parse("{\"a\":1} x"); + }); + } }