From 25ff0355c3a3245d25a0409423ea9f562471dcb5 Mon Sep 17 00:00:00 2001 From: Tyler Gregg Date: Wed, 10 Apr 2024 17:02:14 -0700 Subject: [PATCH] Adds a configurable limit to the number of timestamp fractional second digits that are written to text representations. --- src/main/java/com/amazon/ion/Timestamp.java | 94 +++++++++++++++++-- .../com/amazon/ion/_Private_Trampoline.kt | 9 ++ .../amazon/ion/impl/IonWriterSystemText.java | 6 +- .../impl/_Private_IonTextWriterBuilder.java | 7 ++ .../ion/system/IonTextWriterBuilder.java | 44 +++++++++ .../java/com/amazon/ion/TimestampTest.java | 81 ++++++++++++++++ .../com/amazon/ion/impl/TextWriterTest.java | 7 ++ 7 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/amazon/ion/_Private_Trampoline.kt diff --git a/src/main/java/com/amazon/ion/Timestamp.java b/src/main/java/com/amazon/ion/Timestamp.java index 3ecb91baa3..13057cf689 100644 --- a/src/main/java/com/amazon/ion/Timestamp.java +++ b/src/main/java/com/amazon/ion/Timestamp.java @@ -19,6 +19,7 @@ import static com.amazon.ion.util.IonTextUtils.printCodePointAsString; import com.amazon.ion.impl._Private_Utils; +import com.amazon.ion.system.IonTextWriterBuilder; import com.amazon.ion.util.IonTextUtils; import java.io.IOException; import java.math.BigDecimal; @@ -133,6 +134,18 @@ public final class Timestamp private static final int FLAG_MINUTE = 0x08; private static final int FLAG_SECOND = 0x10; + /** + * The default maximum number of digits in the fractional component that will be written when a text representation + * of a timestamp is requested. If more precision is needed, timestamps can be serialized using a text IonWriter + * configured with {@link IonTextWriterBuilder#withMaximumTimestampPrecisionDigits(int)}. + * + * @see #toString() + * @see #toZString() + * @see #print(Appendable) + * @see #printZ(Appendable) + */ + public static final int DEFAULT_MAXIMUM_DIGITS_TEXT = 10000; + /** * The precision of the Timestamp. */ @@ -2035,16 +2048,34 @@ public Timestamp withLocalOffset(Integer offset) * Returns the string representation (in Ion format) of this Timestamp in * its local time. * + * @throws IonException if the text representation of the timestamp requires more than + * {@link Timestamp#DEFAULT_MAXIMUM_DIGITS_TEXT}. + * * @see #toZString() * @see #print(Appendable) */ @Override public String toString() + { + return toString(DEFAULT_MAXIMUM_DIGITS_TEXT); + } + + /** + * Returns the string representation (in Ion format) of this Timestamp in + * its local time. + * + * @throws IonException if the text representation of the timestamp requires more than + * the given maximum. + * + * @see #toZString() + * @see #print(Appendable) + */ + String toString(int maximumDigits) { StringBuilder buffer = new StringBuilder(32); try { - print(buffer); + print(buffer, maximumDigits); } catch (IOException e) { @@ -2059,6 +2090,9 @@ public String toString() * Returns the string representation (in Ion format) of this Timestamp * in UTC. * + * @throws IonException if the text representation of the timestamp requires more than + * {@link Timestamp#DEFAULT_MAXIMUM_DIGITS_TEXT}. + * * @see #toString() * @see #printZ(Appendable) */ @@ -2087,11 +2121,33 @@ public String toZString() * @param out not {@code null} * * @throws IOException propagated when the {@link Appendable} throws it + * @throws IonException if the text representation of the timestamp requires more than + * {@link Timestamp#DEFAULT_MAXIMUM_DIGITS_TEXT}. * * @see #printZ(Appendable) */ public void print(Appendable out) throws IOException + { + print(out, DEFAULT_MAXIMUM_DIGITS_TEXT); + } + + /** + * Prints to an {@code Appendable} the string representation (in Ion format) + * of this Timestamp in its local time. + *

+ * This method produces the same output as {@link #toString()}. + * + * @param out not {@code null} + * + * @throws IOException propagated when the {@link Appendable} throws it + * @throws IonException if the text representation of the timestamp requires more than + * the given maximum. + * + * @see #printZ(Appendable) + */ + private void print(Appendable out, int maximumDigits) + throws IOException { // we have to make a copy to preserve the "immutable" contract // on Timestamp and we don't want someone reading the calendar @@ -2103,7 +2159,7 @@ public void print(Appendable out) adjusted = make_localtime(); } - print(out, adjusted); + print(out, adjusted, maximumDigits); } @@ -2116,6 +2172,8 @@ public void print(Appendable out) * @param out not {@code null} * * @throws IOException propagated when the {@code Appendable} throws it. + * @throws IonException if the text representation of the timestamp requires more than + * {@link Timestamp#DEFAULT_MAXIMUM_DIGITS_TEXT}. * * @see #print(Appendable) */ @@ -2146,6 +2204,25 @@ public void printZ(Appendable out) } + /** + * Throws if the text representation of the timestamp would require more than the given number of digits in its + * fractional component. + * @param value a Timestamp with a non-null fraction. + * @param maximumDigits the maximum number of digits allowed. + */ + private static void requirePrecisionWithinLimit(Timestamp value, int maximumDigits) { + if (value._fraction.scale() > maximumDigits) { + throw new IonException(String.format( + "Timestamp with %d digits of precision cannot be serialized because it exceeds the " + + "configurable maximum timestamp precision of %d digits. Timestamps that require more digits " + + "may be written using a text writer configured with " + + "IonTextWriterBuilder.withMaximumTimestampPrecisionDigits.", + value._fraction.scale(), + maximumDigits + )); + } + } + /** * helper for print(out) and printZ(out) so that printZ can create * a zulu time and pass it directly and print can apply the local @@ -2153,9 +2230,10 @@ public void printZ(Appendable out) * contract to be immutable). * @param out destination for the text image of the value * @param adjusted the time value with the fields adjusted to match the desired text output + * @param maximumDigits the maximum number of digits allowed in the fractional seconds * @throws IOException */ - private static void print(Appendable out, Timestamp adjusted) + private static void print(Appendable out, Timestamp adjusted, int maximumDigits) throws IOException { // null is our first "guess" to get it out of the way @@ -2164,11 +2242,14 @@ private static void print(Appendable out, Timestamp adjusted) return; } + if (adjusted._precision == Precision.SECOND && adjusted._fraction != null) { + requirePrecisionWithinLimit(adjusted, maximumDigits); + } + // so we have a real value - we'll start with the date portion // which we always have print_digits(out, adjusted._year, 4); if (adjusted._precision == Precision.YEAR) { - assert adjusted._offset == UNKNOWN_OFFSET; out.append("T"); return; } @@ -2176,7 +2257,6 @@ private static void print(Appendable out, Timestamp adjusted) out.append("-"); print_digits(out, adjusted._month, 2); // convert calendar months to a base 1 value if (adjusted._precision == Precision.MONTH) { - assert adjusted._offset == UNKNOWN_OFFSET; out.append("T"); return; } @@ -2184,8 +2264,6 @@ private static void print(Appendable out, Timestamp adjusted) out.append("-"); print_digits(out, adjusted._day, 2); if (adjusted._precision == Precision.DAY) { - assert adjusted._offset == UNKNOWN_OFFSET; - // out.append("T"); return; } @@ -2202,7 +2280,7 @@ private static void print(Appendable out, Timestamp adjusted) } } - if (adjusted._offset != UNKNOWN_OFFSET) { + if (adjusted._offset != null) { int min, hour; min = adjusted._offset; if (min == 0) { diff --git a/src/main/java/com/amazon/ion/_Private_Trampoline.kt b/src/main/java/com/amazon/ion/_Private_Trampoline.kt new file mode 100644 index 0000000000..c4b9680c24 --- /dev/null +++ b/src/main/java/com/amazon/ion/_Private_Trampoline.kt @@ -0,0 +1,9 @@ +package com.amazon.ion + +/** + * **NOT FOR APPLICATION USE. This method may be removed at any time.** + * Trampoline to the non-public `Timestamp.toString(Int)` method. + */ +internal fun printTimestamp(timestamp: Timestamp, maximumDigits: Int): String { + return timestamp.toString(maximumDigits) +} diff --git a/src/main/java/com/amazon/ion/impl/IonWriterSystemText.java b/src/main/java/com/amazon/ion/impl/IonWriterSystemText.java index c0f7de3fc5..bf8f545505 100644 --- a/src/main/java/com/amazon/ion/impl/IonWriterSystemText.java +++ b/src/main/java/com/amazon/ion/impl/IonWriterSystemText.java @@ -16,6 +16,7 @@ package com.amazon.ion.impl; import static com.amazon.ion.SystemSymbols.SYMBOLS; +import static com.amazon.ion._Private_TrampolineKt.printTimestamp; import static com.amazon.ion.impl._Private_IonConstants.tidList; import static com.amazon.ion.impl._Private_IonConstants.tidSexp; import static com.amazon.ion.impl._Private_IonConstants.tidStruct; @@ -641,13 +642,14 @@ public void writeTimestamp(Timestamp value) throws IOException else if (_options._timestamp_as_string) { // Timestamp is ASCII-safe so this is easy + String valueText = printTimestamp(value, _options.getMaximumTimestampPrecisionDigits()); _output.appendAscii('"'); - _output.appendAscii(value.toString()); + _output.appendAscii(valueText); _output.appendAscii('"'); } else { - _output.appendAscii(value.toString()); + _output.appendAscii(printTimestamp(value, _options.getMaximumTimestampPrecisionDigits())); } closeValue(); diff --git a/src/main/java/com/amazon/ion/impl/_Private_IonTextWriterBuilder.java b/src/main/java/com/amazon/ion/impl/_Private_IonTextWriterBuilder.java index ced626d845..9d8ae52c2a 100644 --- a/src/main/java/com/amazon/ion/impl/_Private_IonTextWriterBuilder.java +++ b/src/main/java/com/amazon/ion/impl/_Private_IonTextWriterBuilder.java @@ -199,6 +199,13 @@ private _Private_IonTextWriterBuilder fillDefaults() b.setNewLineType(NewLineType.PLATFORM_DEPENDENT); } + if (b.getMaximumTimestampPrecisionDigits() < 1) { + throw new IllegalArgumentException(String.format( + "Configured maximum timestamp precision must be positive, not %d.", + b.getMaximumTimestampPrecisionDigits() + )); + } + return (_Private_IonTextWriterBuilder) b.immutable(); } diff --git a/src/main/java/com/amazon/ion/system/IonTextWriterBuilder.java b/src/main/java/com/amazon/ion/system/IonTextWriterBuilder.java index b88fb4bca0..40785cd1f4 100644 --- a/src/main/java/com/amazon/ion/system/IonTextWriterBuilder.java +++ b/src/main/java/com/amazon/ion/system/IonTextWriterBuilder.java @@ -20,6 +20,7 @@ import com.amazon.ion.IonCatalog; import com.amazon.ion.IonWriter; import com.amazon.ion.SymbolTable; +import com.amazon.ion.Timestamp; import com.amazon.ion.impl._Private_IonTextWriterBuilder; import com.amazon.ion.impl._Private_Utils; import java.io.OutputStream; @@ -221,6 +222,7 @@ public static IonTextWriterBuilder json() private int myLongStringThreshold; private NewLineType myNewLineType; private boolean myTopLevelValuesOnNewLines; + private int myMaximumTimestampPrecisionDigits = Timestamp.DEFAULT_MAXIMUM_DIGITS_TEXT; /** NOT FOR APPLICATION USE! */ @@ -240,6 +242,7 @@ protected IonTextWriterBuilder(IonTextWriterBuilder that) this.myLongStringThreshold = that.myLongStringThreshold; this.myNewLineType = that.myNewLineType; this.myTopLevelValuesOnNewLines = that.myTopLevelValuesOnNewLines; + this.myMaximumTimestampPrecisionDigits = that.myMaximumTimestampPrecisionDigits; } @@ -764,6 +767,47 @@ public final IonTextWriterBuilder withWriteTopLevelValuesOnNewLines(boolean writ //========================================================================= + /** + * Gets the maximum number of digits of fractional second precision allowed to be written for timestamp values. + * + * @return the currently configured maximum. + * + * @see #setMaximumTimestampPrecisionDigits(int) + * @see #withMaximumTimestampPrecisionDigits(int) + */ + public final int getMaximumTimestampPrecisionDigits() { + return myMaximumTimestampPrecisionDigits; + } + + /** + * Sets the maximum number of digits of fractional second precision allowed to be written for timestamp values. + * Default: {@link Timestamp#DEFAULT_MAXIMUM_DIGITS_TEXT}. + * + * @see #getMaximumTimestampPrecisionDigits() + * @see #withMaximumTimestampPrecisionDigits(int) + */ + public void setMaximumTimestampPrecisionDigits(int maximumTimestampPrecisionDigits) { + mutationCheck(); + myMaximumTimestampPrecisionDigits = maximumTimestampPrecisionDigits; + } + + /** + * Sets the maximum number of digits of fractional second precision allowed to be written for timestamp values. + * Default: {@link Timestamp#DEFAULT_MAXIMUM_DIGITS_TEXT}. + * + * @return this instance, if mutable; otherwise a mutable copy of this instance. + * + * @see #getMaximumTimestampPrecisionDigits() + * @see #setMaximumTimestampPrecisionDigits(int) + */ + public final IonTextWriterBuilder withMaximumTimestampPrecisionDigits(int maximumTimestampPrecisionDigits) { + IonTextWriterBuilder b = mutable(); + b.setMaximumTimestampPrecisionDigits(maximumTimestampPrecisionDigits); + return b; + } + + //========================================================================= + /** * Creates a new writer that will write text to the given output * stream. diff --git a/src/test/java/com/amazon/ion/TimestampTest.java b/src/test/java/com/amazon/ion/TimestampTest.java index 1709fe8da8..819f980a19 100644 --- a/src/test/java/com/amazon/ion/TimestampTest.java +++ b/src/test/java/com/amazon/ion/TimestampTest.java @@ -17,6 +17,7 @@ import static com.amazon.ion.Decimal.NEGATIVE_ZERO; import static com.amazon.ion.Decimal.negativeZero; +import static com.amazon.ion.Timestamp.DEFAULT_MAXIMUM_DIGITS_TEXT; import static com.amazon.ion.Timestamp.MAXIMUM_ALLOWED_TIMESTAMP_IN_MILLIS_DECIMAL; import static com.amazon.ion.Timestamp.MINIMUM_TIMESTAMP_IN_MILLIS; import static com.amazon.ion.Timestamp.MINIMUM_TIMESTAMP_IN_MILLIS_DECIMAL; @@ -32,6 +33,8 @@ import static com.amazon.ion.impl._Private_Utils.UTC; import com.amazon.ion.Timestamp.Precision; + +import java.io.IOException; import java.math.BigDecimal; import java.text.DateFormat; import java.text.ParseException; @@ -40,7 +43,9 @@ import java.util.Date; import java.util.GregorianCalendar; import java.util.TimeZone; +import java.util.function.Function; +import com.amazon.ion.system.IonTextWriterBuilder; import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; @@ -3098,4 +3103,80 @@ public void testInstantVsTimestampMillis() { assertEquals(millisFromTimestamp, ts2.getMillis()); */ } + + private void expectCleanToStringFailure(Timestamp timestamp, Function function) { + try { + function.apply(timestamp); + fail(); + } catch (IonException e) { + // Expected. + } + } + + @FunctionalInterface + private interface TimestampPrintFunction { + void print(Timestamp timestamp, Appendable appendable) throws IOException; + } + + private void expectCleanPrintFailure(Timestamp timestamp, TimestampPrintFunction function) throws IOException { + StringBuilder sb = new StringBuilder(); + try { + function.print(timestamp, sb); + } catch (IonException e) { + // Expected. + } + assertTrue(sb.toString().isEmpty()); + } + + @Test + public void testCleanFailureWhenAttemptingToWriteVeryPreciseTimestampToString() throws IOException { + Timestamp timestamp = Timestamp.forSecond(2024, 4, 10, 10, 56, BigDecimal.valueOf(0, Timestamp.DEFAULT_MAXIMUM_DIGITS_TEXT + 1), 0); + expectCleanToStringFailure(timestamp, Timestamp::toString); + expectCleanToStringFailure(timestamp, Timestamp::toZString); + expectCleanPrintFailure(timestamp, Timestamp::print); + expectCleanPrintFailure(timestamp, Timestamp::printZ); + } + + private void expectEquivalentTimestampStrings(Timestamp timestamp, Function toStringFunction, TimestampPrintFunction printFunction) throws IOException { + String text = toStringFunction.apply(timestamp); + StringBuilder sb = new StringBuilder(); + printFunction.print(timestamp, sb); + assertEquals(text, sb.toString()); + assertFalse(text.isEmpty()); + } + + @Test + public void testSuccessWhenWritingTimestampWithPrecisionAtMaximum() throws IOException { + Timestamp timestamp = Timestamp.forSecond(2024, 4, 10, 10, 56, BigDecimal.valueOf(0, Timestamp.DEFAULT_MAXIMUM_DIGITS_TEXT), 0); + expectEquivalentTimestampStrings(timestamp, Timestamp::toString, Timestamp::print); + expectEquivalentTimestampStrings(timestamp, Timestamp::toZString, Timestamp::printZ); + } + + private void testAtAndAboveMaximumDigits(int maximum) throws IOException { + Timestamp aboveMaximum = Timestamp.forSecond(2024, 4, 10, 10, 56, BigDecimal.valueOf(0, maximum + 1), 0); + Timestamp atMaximum = Timestamp.forSecond(2024, 4, 10, 10, 56, BigDecimal.valueOf(0, maximum), 0); + StringBuilder sb = new StringBuilder(); + IonTextWriterBuilder builder = IonTextWriterBuilder.standard(); + if (maximum != DEFAULT_MAXIMUM_DIGITS_TEXT) { + builder = builder.withMaximumTimestampPrecisionDigits(maximum); + } + try (IonWriter writer = builder.build(sb)) { + try { + writer.writeTimestamp(aboveMaximum); + fail(); + } catch (IonException e) { + // Expected. + } + // The error is recoverable, so subsequently writing a valid timestamp should work. + writer.writeTimestamp(atMaximum); + } + assertEquals(atMaximum.toString(), sb.toString()); + } + + @Test + public void testCleanFailureWhenAttemptingToWriteVeryPreciseTimestampWithTextWriter() throws IOException { + testAtAndAboveMaximumDigits(DEFAULT_MAXIMUM_DIGITS_TEXT); + testAtAndAboveMaximumDigits(DEFAULT_MAXIMUM_DIGITS_TEXT - 1); + testAtAndAboveMaximumDigits(42); + } } diff --git a/src/test/java/com/amazon/ion/impl/TextWriterTest.java b/src/test/java/com/amazon/ion/impl/TextWriterTest.java index 60dd1ccf74..3090dc73fb 100644 --- a/src/test/java/com/amazon/ion/impl/TextWriterTest.java +++ b/src/test/java/com/amazon/ion/impl/TextWriterTest.java @@ -32,6 +32,8 @@ import com.amazon.ion.system.IonTextWriterBuilder; import com.amazon.ion.system.IonTextWriterBuilder.LstMinimizing; import com.amazon.ion.system.IonWriterBuilder.IvmMinimizing; + +import java.io.IOException; import java.io.OutputStream; import org.junit.Test; @@ -616,6 +618,11 @@ public void testAnnotationNotSetToIvmOnStartOfStream() super.testAnnotationNotSetToIvmOnStartOfStream(); } + @Test(expected = IllegalArgumentException.class) + public void testNegativeMaximumTimestampDigitsFails() throws IOException { + try (IonWriter writer = IonTextWriterBuilder.standard().withMaximumTimestampPrecisionDigits(-1).build(new StringBuilder())) {} + } + @Override protected void checkFlushedAfterTopLevelValueWritten() {