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..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 @@ -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; @@ -183,6 +184,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 +961,56 @@ 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()); + } + + @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 { + + 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..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; } @@ -368,6 +371,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 +407,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..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); @@ -472,6 +478,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..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 @@ -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'", @@ -452,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-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-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..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 @@ -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,65 @@ 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 + * 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) { + 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)); + } + } + 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( + "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) { 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..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 @@ -17,6 +17,7 @@ package org.apache.hugegraph.core; +import java.math.BigDecimal; import java.util.Date; import org.apache.hugegraph.HugeException; @@ -391,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", @@ -741,4 +777,64 @@ 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()); + }); + // 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") + .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/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(); 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/core/DataTypeTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/DataTypeTest.java index 3ccff1a131..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 @@ -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,110 @@ 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 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 + 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/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-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..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; } @@ -373,6 +376,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)) { @@ -424,6 +431,9 @@ public String convert2Groovy(boolean attachIdFlag) { case UUID: builder.append(".asUUID()"); break; + case DECIMAL: + builder.append(".asDecimal()"); + break; case OBJECT: builder.append(".asObject()"); break; @@ -548,6 +558,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 6a04a83034..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 @@ -19,6 +19,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; @@ -46,7 +48,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 +116,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))) { @@ -149,6 +160,55 @@ 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) { + 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)); + } + } + 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( + "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) { 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 d8441144f2..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 @@ -17,11 +17,15 @@ 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; 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 +71,108 @@ 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()); + } + + @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()); + }); + // 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 + 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); + } }