Improve serialization defensiveness of Revision
[yangtools.git] / common / yang-common / src / main / java / org / opendaylight / yangtools / yang / common / Decimal64.java
index a16ab1b4ef76feb95ae52fc8a4bd14cfce2547b8..a8eba1b9c58427cacbbc5545a67ad8844117a0bf 100644 (file)
@@ -9,11 +9,12 @@ package org.opendaylight.yangtools.yang.common;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Verify.verify;
+import static java.util.Objects.requireNonNull;
 
-import com.google.common.annotations.Beta;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
+import java.io.Serial;
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.Optional;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
@@ -22,10 +23,7 @@ import org.opendaylight.yangtools.concepts.Either;
 /**
  * Dedicated type for YANG's 'type decimal64' type. This class is similar to {@link BigDecimal}, but provides more
  * efficient storage, as it has fixed precision.
- *
- * @author Robert Varga
  */
-@Beta
 @NonNullByDefault
 public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
     public static final class Support extends AbstractCanonicalValueSupport<Decimal64> {
@@ -35,7 +33,7 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
 
         @Override
         public Either<Decimal64, CanonicalValueViolation> fromString(final String str) {
-            // https://tools.ietf.org/html/rfc6020#section-9.3.1
+            // https://www.rfc-editor.org/rfc/rfc6020#section-9.3.1
             //
             // A decimal64 value is lexically represented as an optional sign ("+"
             // or "-"), followed by a sequence of decimal digits, optionally
@@ -47,21 +45,20 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
 
             // Deal with optional sign
             final boolean negative;
-            int idx;
-            switch (str.charAt(0)) {
-                case '-':
+            int idx = switch (str.charAt(0)) {
+                case '-' -> {
                     negative = true;
-                    idx = 1;
-                    break;
-                case '+':
+                    yield 1;
+                }
+                case '+' -> {
                     negative = false;
-                    idx = 1;
-                    break;
-                default:
+                    yield 1;
+                }
+                default -> {
                     negative = false;
-                    idx = 0;
-            }
-
+                    yield 0;
+                }
+            };
             // Sanity check length
             if (idx == str.length()) {
                 return CanonicalValueViolation.variantOf("Missing digits after sign");
@@ -88,9 +85,9 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
                     // Fractions are next
                     break;
                 }
-                if (intLen == MAX_FRACTION_DIGITS) {
+                if (intLen == MAX_SCALE) {
                     return CanonicalValueViolation.variantOf(
-                        "Integer part is longer than " + MAX_FRACTION_DIGITS + " digits");
+                        "Integer part is longer than " + MAX_SCALE + " digits");
                 }
 
                 intPart = 10 * intPart + toInt(ch, idx);
@@ -112,7 +109,7 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
                 limit--;
             }
 
-            final int fracLimit = MAX_FRACTION_DIGITS - intLen;
+            final int fracLimit = MAX_SCALE - intLen + 1;
             byte fracLen = 0;
             long fracPart = 0;
             for (; idx <= limit; idx++, fracLen++) {
@@ -135,12 +132,44 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         }
     }
 
+    /**
+     * Tri-state indicator of how a non-zero remainder is significant to rounding.
+     */
+    private enum RemainderSignificance {
+        /**
+         * The remainder is less than the half of the interval.
+         */
+        LT_HALF,
+        /**
+         * The remainder is exactly half of the interval.
+         */
+        HALF,
+        /**
+         * The remainder is greater than the half of the interval.
+         */
+        GT_HALF;
+
+        static RemainderSignificance of(final long remainder, final long interval) {
+            final long absRemainder = Math.abs(remainder);
+            final long half = interval / 2;
+
+            if (absRemainder > half) {
+                return GT_HALF;
+            } else if (absRemainder < half) {
+                return LT_HALF;
+            } else {
+                return HALF;
+            }
+        }
+    }
+
     private static final CanonicalValueSupport<Decimal64> SUPPORT = new Support();
+    @Serial
     private static final long serialVersionUID = 1L;
 
-    private static final int MAX_FRACTION_DIGITS = 18;
+    private static final int MAX_SCALE = 18;
 
-    private static final long[] SCALE = {
+    private static final long[] FACTOR = {
         10,
         100,
         1000,
@@ -161,51 +190,137 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         1000000000000000000L
     };
 
+    private static final Decimal64Conversion[] CONVERSION = Decimal64Conversion.values();
+    private static final Decimal64[] MIN_VALUE;
+    private static final Decimal64[] MAX_VALUE;
+
     static {
-        verify(SCALE.length == MAX_FRACTION_DIGITS);
+        verify(CONVERSION.length == MAX_SCALE);
+        verify(FACTOR.length == MAX_SCALE);
+
+        MIN_VALUE = new Decimal64[MAX_SCALE];
+        MAX_VALUE = new Decimal64[MAX_SCALE];
+        for (byte i = 0; i < MAX_SCALE; ++i) {
+            MIN_VALUE[i] = new Decimal64(i, Long.MIN_VALUE);
+            MAX_VALUE[i] = new Decimal64(i, Long.MAX_VALUE);
+        }
     }
 
-    private final byte scaleOffset;
+    private final byte offset;
     private final long value;
 
     @VisibleForTesting
-    Decimal64(final int fractionDigits, final long intPart, final long fracPart, final boolean negative) {
-        checkArgument(fractionDigits >= 1 && fractionDigits <= MAX_FRACTION_DIGITS);
-        this.scaleOffset = (byte) (fractionDigits - 1);
+    Decimal64(final int scale, final long intPart, final long fracPart, final boolean negative) {
+        offset = offsetOf(scale);
 
-        final long bits = intPart * SCALE[this.scaleOffset] + fracPart;
-        this.value = negative ? -bits : bits;
+        final long bits = intPart * FACTOR[offset] + fracPart;
+        value = negative ? -bits : bits;
+    }
+
+    private Decimal64(final byte offset, final long intPart, final boolean negative) {
+        this.offset = offset;
+        final long bits = intPart * FACTOR[offset];
+        value = negative ? -bits : bits;
+    }
+
+    private Decimal64(final byte offset, final long value) {
+        this.offset = offset;
+        this.value = value;
     }
 
     protected Decimal64(final Decimal64 other) {
-        this.scaleOffset = other.scaleOffset;
-        this.value = other.value;
+        this(other.offset, other.value);
+    }
+
+    /**
+     * Return a {@link Decimal64} with specified scale and unscaled value.
+     *
+     * @param scale scale to use
+     * @param unscaledValue unscaled value to use
+     * @return A Decimal64 instance
+     * @throws IllegalArgumentException if {@code scale} is not in range {@code [1..18]}
+     */
+    public static Decimal64 of(final int scale, final long unscaledValue) {
+        return new Decimal64(offsetOf(scale), unscaledValue);
     }
 
-    public static Decimal64 valueOf(final byte byteVal) {
-        return byteVal < 0 ? new Decimal64(1, -byteVal, 0, true) : new Decimal64(1, byteVal, 0, false);
+    /**
+     * Return the minimum value supported in specified scale.
+     *
+     * @param scale scale to use
+     * @return Minimum value in that scale
+     * @throws IllegalArgumentException if {@code scale} is not in range {@code [1..18]}
+     */
+    public static Decimal64 minValueIn(final int scale) {
+        return MIN_VALUE[offsetOf(scale)];
     }
 
-    public static Decimal64 valueOf(final short shortVal) {
-        return shortVal < 0 ? new Decimal64(1, -shortVal, 0, true) : new Decimal64(1, shortVal, 0, false);
+    /**
+     * Return the maximum value supported in specified scale.
+     *
+     * @param scale scale to use
+     * @return Maximum value in that scale
+     * @throws IllegalArgumentException if {@code scale} is not in range {@code [1..18]}
+     */
+    public static Decimal64 maxValueIn(final int scale) {
+        return MAX_VALUE[offsetOf(scale)];
     }
 
-    public static Decimal64 valueOf(final int intVal) {
-        return intVal < 0 ? new Decimal64(1, - (long)intVal, 0, true) : new Decimal64(1, intVal, 0, false);
+    // >>> FIXME: these need truncating counterparts
+    public static Decimal64 valueOf(final int scale, final byte byteVal) {
+        final byte offset = offsetOf(scale);
+        final var conv = CONVERSION[offset];
+        if (byteVal < conv.minByte || byteVal > conv.maxByte) {
+            throw iae(scale, byteVal, conv);
+        }
+        return byteVal < 0 ? new Decimal64(offset, -byteVal, true) : new Decimal64(offset, byteVal, false);
+    }
+
+    public static Decimal64 valueOf(final int scale, final short shortVal) {
+        final byte offset = offsetOf(scale);
+        final var conv = CONVERSION[offset];
+        if (shortVal < conv.minShort || shortVal > conv.maxShort) {
+            throw iae(scale, shortVal, conv);
+        }
+        return shortVal < 0 ? new Decimal64(offset, -shortVal, true) : new Decimal64(offset, shortVal, false);
+    }
+
+    public static Decimal64 valueOf(final int scale, final int intVal) {
+        final byte offset = offsetOf(scale);
+        final var conv = CONVERSION[offset];
+        if (intVal < conv.minInt || intVal > conv.maxInt) {
+            throw iae(scale, intVal, conv);
+        }
+        return intVal < 0 ? new Decimal64(offset, - (long)intVal, true) : new Decimal64(offset, intVal, false);
+    }
+
+    public static Decimal64 valueOf(final int scale, final long longVal) {
+        final byte offset = offsetOf(scale);
+        final var conv = CONVERSION[offset];
+        if (longVal < conv.minLong || longVal > conv.maxLong) {
+            throw iae(scale, longVal, conv);
+        }
+        return longVal < 0 ? new Decimal64(offset, -longVal, true) : new Decimal64(offset, longVal, false);
     }
 
-    public static Decimal64 valueOf(final long longVal) {
+    // <<< FIXME
+
+    // FIXME: this should take a RoundingMode and perform rounding
+    // FIXME: this should have a truncating counterpart
+    public static Decimal64 valueOf(final float floatVal, final RoundingMode rounding) {
         // XXX: we should be able to do something smarter here
-        return valueOf(Long.toString(longVal));
+        return valueOf(Float.toString(floatVal));
     }
 
-    public static Decimal64 valueOf(final double doubleVal) {
+    // FIXME: this should take a RoundingMode and perform rounding
+    // FIXME: this should have a truncating counterpart
+    public static Decimal64 valueOf(final double doubleVal, final RoundingMode rounding) {
         // XXX: we should be able to do something smarter here
         return valueOf(Double.toString(doubleVal));
     }
 
     public static Decimal64 valueOf(final BigDecimal decimalVal) {
-        // XXX: we should be able to do something smarter here
+        // FIXME: we should be able to do something smarter here using BigDecimal.unscaledValue() and BigDecimal.scale()
         return valueOf(decimalVal.toPlainString());
     }
 
@@ -222,14 +337,113 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         final Either<Decimal64, CanonicalValueViolation> variant = SUPPORT.fromString(str);
         final Optional<Decimal64> value = variant.tryFirst();
         if (value.isPresent()) {
-            return value.get();
+            return value.orElseThrow();
         }
         final Optional<String> message = variant.getSecond().getMessage();
-        throw message.isPresent() ? new NumberFormatException(message.get()) : new NumberFormatException();
+        throw message.isPresent() ? new NumberFormatException(message.orElseThrow()) : new NumberFormatException();
+    }
+
+    /**
+     * Return the scale of this decimal. This is the number of fraction digits, in range {@code [1..18]}.
+     *
+     * @return This decimal's scale
+     */
+    public final int scale() {
+        return offset + 1;
+    }
+
+    /**
+     * Return the unscaled value of this decimal.
+     *
+     * @return This decimal's unscaled value
+     */
+    public final long unscaledValue() {
+        return value;
+    }
+
+    /**
+     * Return this decimal in the specified scale.
+     *
+     * @param scale target scale
+     * @return Scaled number
+     * @throws ArithmeticException if the conversion would overflow or require rounding
+     */
+    public Decimal64 scaleTo(final int scale) {
+        return scaleTo(scale, RoundingMode.UNNECESSARY);
+    }
+
+    /**
+     * Return this decimal in the specified scale.
+     *
+     * @param scale scale
+     * @param roundingMode rounding mode
+     * @return Scaled number
+     * @throws ArithmeticException if the conversion would overflow or require rounding and {@code roundingMode} is
+     *                             {@link RoundingMode#UNNECESSARY}.
+     * @throws IllegalArgumentException if {@code scale} is not valid
+     * @throws NullPointerException if {@code roundingMode} is {@code null}
+     */
+    public Decimal64 scaleTo(final int scale, final RoundingMode roundingMode) {
+        final var mode = requireNonNull(roundingMode);
+        final byte scaleOffset = offsetOf(scale);
+        final int diff = scaleOffset - offset;
+        if (diff == 0) {
+            // Same scale, no-op
+            return this;
+        } else if (value == 0) {
+            // Zero is special, as it has the same unscaled value in all scales
+            return new Decimal64(scaleOffset, 0);
+        }
+
+        if (diff > 0) {
+            // Increasing scale is simple, as we have pre-calculated min/max boundaries and then it's just
+            // factor multiplication
+            final int diffOffset = diff - 1;
+            final var conv = CONVERSION[diffOffset];
+            if (value < conv.minLong || value > conv.maxLong) {
+                throw new ArithmeticException("Increasing scale of " + this + " to " + scale + " would overflow");
+            }
+            return new Decimal64(scaleOffset, value * FACTOR[diffOffset]);
+        }
+
+        // Decreasing scale is hard, as we need to deal with rounding
+        final int diffOffset = -diff - 1;
+        final long factor = FACTOR[diffOffset];
+        final long trunc = value / factor;
+        final long remainder = value - trunc * factor;
+
+        // No remainder, we do not need to involve rounding
+        if (remainder == 0) {
+            return new Decimal64(scaleOffset, trunc);
+        }
+
+        final long increment = switch (mode) {
+            case UP -> Long.signum(trunc);
+            case DOWN -> 0;
+            case CEILING -> Long.signum(trunc) > 0 ? 1 : 0;
+            case FLOOR -> Long.signum(trunc) < 0 ? -1 : 0;
+            case HALF_UP -> switch (RemainderSignificance.of(remainder, factor)) {
+                case LT_HALF -> 0;
+                case HALF, GT_HALF -> Long.signum(trunc);
+            };
+            case HALF_DOWN -> switch (RemainderSignificance.of(remainder, factor)) {
+                case LT_HALF, HALF -> 0;
+                case GT_HALF -> Long.signum(trunc);
+            };
+            case HALF_EVEN -> switch (RemainderSignificance.of(remainder, factor)) {
+                case LT_HALF -> 0;
+                case HALF -> (trunc & 0x1) != 0 ? Long.signum(trunc) : 0;
+                case GT_HALF -> Long.signum(trunc);
+            };
+            case UNNECESSARY ->
+                throw new ArithmeticException("Decreasing scale of " + this + " to " + scale + " requires rounding");
+        };
+
+        return new Decimal64(scaleOffset, trunc + increment);
     }
 
     public final BigDecimal decimalValue() {
-        return BigDecimal.valueOf(value, scaleOffset + 1);
+        return BigDecimal.valueOf(value, scale());
     }
 
     @Override
@@ -249,7 +463,7 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
 
     @Override
     public final double doubleValue() {
-        return 1.0 * value / SCALE[scaleOffset];
+        return 1.0 * value / FACTOR[offset];
     }
 
     /**
@@ -264,7 +478,7 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         final long val = longValueExact();
         final byte ret = (byte) val;
         if (val != ret) {
-            throw new ArithmeticException("Value " + val + " is outside of byte range");
+            throw ae("byte", val);
         }
         return ret;
     }
@@ -281,7 +495,7 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         final long val = longValueExact();
         final short ret = (short) val;
         if (val != ret) {
-            throw new ArithmeticException("Value " + val + " is outside of short range");
+            throw ae("short", val);
         }
         return ret;
     }
@@ -298,7 +512,7 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         final long val = longValueExact();
         final int ret = (int) val;
         if (val != ret) {
-            throw new ArithmeticException("Value " + val + " is outside of integer range");
+            throw ae("integer", val);
         }
         return ret;
     }
@@ -323,7 +537,7 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         if (this == o) {
             return 0;
         }
-        if (scaleOffset == o.scaleOffset) {
+        if (offset == o.offset) {
             return Long.compare(value, o.value);
         }
 
@@ -333,23 +547,39 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
 
     @Override
     public final String toCanonicalString() {
-        // https://tools.ietf.org/html/rfc6020#section-9.3.2
+        // https://www.rfc-editor.org/rfc/rfc6020#section-9.3.2
         //
         // The canonical form of a positive decimal64 does not include the sign
         // "+".  The decimal point is required.  Leading and trailing zeros are
         // prohibited, subject to the rule that there MUST be at least one digit
         // before and after the decimal point.  The value zero is represented as
         // "0.0".
-        final StringBuilder sb = new StringBuilder(21).append(intPart()).append('.');
-        final long fracPart = fracPart();
-        if (fracPart != 0) {
-            // We may need to zero-pad the fraction part
-            sb.append(Strings.padStart(Long.toString(fracPart), scaleOffset + 1, '0'));
-        } else {
-            sb.append('0');
+
+        // Pad unscaled value to scale + 1 size string starting after optional '-' sign
+        final var builder = new StringBuilder(21).append(value);
+        final int start = value < 0 ? 1 : 0;
+        final int scale = scale();
+        final int padding = scale + 1 + start - builder.length();
+        if (padding > 0) {
+            builder.insert(start, "0".repeat(padding));
         }
 
-        return sb.toString();
+        // The first digit of the fraction part is now 'scale' from the end. We will insert the decimal point there,
+        // but also we it is the digit we never trim.
+        final int length = builder.length();
+        final int firstDecimal = length - scale;
+
+        // Remove trailing '0's from decimal part. We walk backwards from the last character stop at firstDecimal
+        int significantLength = length;
+        for (int i = length - 1; i > firstDecimal && builder.charAt(i) == '0'; --i) {
+            significantLength = i;
+        }
+        if (significantLength != length) {
+            builder.setLength(significantLength);
+        }
+
+        // Insert '.' before the first decimal and we're done
+        return builder.insert(firstDecimal, '.').toString();
     }
 
     @Override
@@ -365,7 +595,7 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
 
     @Override
     public final boolean equals(final @Nullable Object obj) {
-        return this == obj || obj instanceof Decimal64 && equalsImpl((Decimal64) obj);
+        return this == obj || obj instanceof Decimal64 other && equalsImpl(other);
     }
 
     /**
@@ -384,16 +614,30 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
     }
 
     private boolean equalsImpl(final Decimal64 other) {
-        return scaleOffset == other.scaleOffset ? value == other.value
+        return offset == other.offset ? value == other.value
                 // We need to normalize both
                 : intPart() == other.intPart() && fracPart() == other.fracPart();
     }
 
     private long intPart() {
-        return value / SCALE[scaleOffset];
+        return value / FACTOR[offset];
     }
 
     private long fracPart() {
-        return Math.abs(value % SCALE[scaleOffset]);
+        return value % FACTOR[offset];
+    }
+
+    private static byte offsetOf(final int scale) {
+        checkArgument(scale >= 1 && scale <= MAX_SCALE, "Scale %s is not in range [1..%s]", scale, MAX_SCALE);
+        return (byte) (scale - 1);
+    }
+
+    private static ArithmeticException ae(final String type, final long val) {
+        return new ArithmeticException("Value " + val + " is outside of " + type + " range");
+    }
+
+    private static IllegalArgumentException iae(final int scale, final long longVal, final Decimal64Conversion conv) {
+        return new IllegalArgumentException("Value " + longVal + " is not in range ["
+            + conv.minLong + ".." + conv.maxLong + "] to fit scale " + scale);
     }
 }