This is an automated email from the ASF dual-hosted git repository.

opwvhk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/avro.git


The following commit(s) were added to refs/heads/master by this push:
     new abe6d421e AVRO-2123: Java duration logical type (#2520)
abe6d421e is described below

commit abe6d421e82fa1d7e2ff4ae19b8a73f2e75ca396
Author: Oscar Westra van Holthe - Kind <[email protected]>
AuthorDate: Thu Oct 5 10:38:54 2023 +0200

    AVRO-2123: Java duration logical type (#2520)
    
    This adds a new `java.time.TemporalAmount` implementation, which
    supports the Avro `duration` logical type (neither `java.time.Period`
    nor `java.time.Duration` support it).
    
    This type is then used in the conversion for the (new) logical type
    implementation "duration".
    
    Last, the logical type "uuid" is refactored to include validation.
---
 .../src/main/java/org/apache/avro/Conversions.java |  42 ++-
 .../main/java/org/apache/avro/LogicalTypes.java    |  47 ++-
 .../main/java/org/apache/avro/util/TimePeriod.java | 393 +++++++++++++++++++++
 .../test/java/org/apache/avro/TestLogicalType.java |  37 +-
 .../avro/generic/TestGenericLogicalTypes.java      |  85 +++--
 .../java/org/apache/avro/util/TimePeriodTest.java  | 306 ++++++++++++++++
 6 files changed, 876 insertions(+), 34 deletions(-)

diff --git a/lang/java/avro/src/main/java/org/apache/avro/Conversions.java 
b/lang/java/avro/src/main/java/org/apache/avro/Conversions.java
index 7d01fc62a..1a1754226 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/Conversions.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/Conversions.java
@@ -18,15 +18,18 @@
 
 package org.apache.avro;
 
-import java.math.RoundingMode;
 import org.apache.avro.generic.GenericData;
 import org.apache.avro.generic.GenericEnumSymbol;
 import org.apache.avro.generic.GenericFixed;
 import org.apache.avro.generic.IndexedRecord;
+import org.apache.avro.util.TimePeriod;
 
 import java.math.BigDecimal;
 import java.math.BigInteger;
+import java.math.RoundingMode;
 import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.IntBuffer;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Map;
@@ -147,6 +150,43 @@ public class Conversions {
     }
   }
 
+  public static class DurationConversion extends Conversion<TimePeriod> {
+    @Override
+    public Class<TimePeriod> getConvertedType() {
+      return TimePeriod.class;
+    }
+
+    @Override
+    public String getLogicalTypeName() {
+      return "duration";
+    }
+
+    @Override
+    public Schema getRecommendedSchema() {
+      return 
LogicalTypes.duration().addToSchema(Schema.createFixed("time.Duration",
+          "A 12-byte byte array encoding a duration in months, days and 
milliseconds.", null, 12));
+    }
+
+    @Override
+    public TimePeriod fromFixed(GenericFixed value, Schema schema, LogicalType 
type) {
+      IntBuffer buffer = 
ByteBuffer.wrap(value.bytes()).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer();
+      long months = Integer.toUnsignedLong(buffer.get());
+      long days = Integer.toUnsignedLong(buffer.get());
+      long millis = Integer.toUnsignedLong(buffer.get());
+      return TimePeriod.of(months, days, millis);
+    }
+
+    @Override
+    public GenericFixed toFixed(TimePeriod value, Schema schema, LogicalType 
type) {
+      ByteBuffer buffer = 
ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);
+      IntBuffer intBuffer = buffer.asIntBuffer();
+      intBuffer.put((int) value.getMonths());
+      intBuffer.put((int) value.getDays());
+      intBuffer.put((int) value.getMillis());
+      return new GenericData.Fixed(schema, buffer.array());
+    }
+  }
+
   /**
    * Convert an underlying representation of a logical type (such as a 
ByteBuffer)
    * to a higher level object (such as a BigDecimal).
diff --git a/lang/java/avro/src/main/java/org/apache/avro/LogicalTypes.java 
b/lang/java/avro/src/main/java/org/apache/avro/LogicalTypes.java
index 086c5d266..4292756a2 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/LogicalTypes.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/LogicalTypes.java
@@ -18,15 +18,15 @@
 
 package org.apache.avro;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.util.Collections;
 import java.util.Map;
 import java.util.Objects;
 import java.util.ServiceLoader;
 import java.util.concurrent.ConcurrentHashMap;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 public class LogicalTypes {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(LogicalTypes.class);
@@ -182,6 +182,7 @@ public class LogicalTypes {
   }
 
   private static final String DECIMAL = "decimal";
+  private static final String DURATION = "duration";
   private static final String UUID = "uuid";
   private static final String DATE = "date";
   private static final String TIME_MILLIS = "time-millis";
@@ -201,12 +202,18 @@ public class LogicalTypes {
     return new Decimal(precision, scale);
   }
 
-  private static final LogicalType UUID_TYPE = new LogicalType("uuid");
+  private static final LogicalType UUID_TYPE = new Uuid();
 
   public static LogicalType uuid() {
     return UUID_TYPE;
   }
 
+  private static final LogicalType DURATION_TYPE = new Duration();
+
+  public static LogicalType duration() {
+    return DURATION_TYPE;
+  }
+
   private static final Date DATE_TYPE = new Date();
 
   public static Date date() {
@@ -249,6 +256,38 @@ public class LogicalTypes {
     return LOCAL_TIMESTAMP_MICROS_TYPE;
   }
 
+  /** Uuid represents a uuid without a time */
+  public static class Uuid extends LogicalType {
+    private Uuid() {
+      super(UUID);
+    }
+
+    @Override
+    public void validate(Schema schema) {
+      super.validate(schema);
+      if (schema.getType() != Schema.Type.STRING) {
+        throw new IllegalArgumentException("Uuid can only be used with an 
underlying string type");
+      }
+    }
+  }
+
+  /**
+   * Duration represents a duration, consisting on months, days and 
milliseconds
+   */
+  public static class Duration extends LogicalType {
+    private Duration() {
+      super(DURATION);
+    }
+
+    @Override
+    public void validate(Schema schema) {
+      super.validate(schema);
+      if (schema.getType() != Schema.Type.FIXED || schema.getFixedSize() != 
12) {
+        throw new IllegalArgumentException("Duration can only be used with an 
underlying fixed type of size 12.");
+      }
+    }
+  }
+
   /** Decimal represents arbitrary-precision fixed-scale decimal numbers */
   public static class Decimal extends LogicalType {
     private static final String PRECISION_PROP = "precision";
diff --git a/lang/java/avro/src/main/java/org/apache/avro/util/TimePeriod.java 
b/lang/java/avro/src/main/java/org/apache/avro/util/TimePeriod.java
new file mode 100644
index 000000000..a1f7fa4e8
--- /dev/null
+++ b/lang/java/avro/src/main/java/org/apache/avro/util/TimePeriod.java
@@ -0,0 +1,393 @@
+/*
+ * 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
+ *
+ *     https://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.avro.util;
+
+import java.io.Serializable;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Period;
+import java.time.chrono.ChronoPeriod;
+import java.time.chrono.IsoChronology;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.Temporal;
+import java.time.temporal.TemporalAmount;
+import java.time.temporal.TemporalUnit;
+import java.time.temporal.UnsupportedTemporalTypeException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import static java.time.temporal.ChronoUnit.DAYS;
+import static java.time.temporal.ChronoUnit.MILLIS;
+import static java.time.temporal.ChronoUnit.MONTHS;
+import static java.util.Collections.unmodifiableList;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * <p>
+ * A temporal amount to model an {@link org.apache.avro.LogicalTypes.Duration
+ * Avro duration} (the logical type).
+ * </p>
+ *
+ * <p>
+ * It consists of a number of months, days and milliseconds, all modelled as an
+ * unsigned integer.
+ * </p>
+ *
+ * <p>
+ * Compared to {@link Period java.time.Period}, this class has a smaller range
+ * ('only' supporting a little less than 358 million years), and cannot support
+ * negative time periods.
+ * </p>
+ *
+ * <p>
+ * Compared to {@link Duration java.time.Duration}, this class has less
+ * precision (milliseconds compared to nanoseconds), cannot support negative
+ * durations, and has a much smaller range. Where {@code java.time.Duration}
+ * supports fixed ranges up to about 68 years, {@code TimePeriod} can only
+ * handle about 49 days.
+ * </p>
+ *
+ * <p>
+ * Comparison with the regular {@code java.time} classes:
+ * </p>
+ *
+ * <table>
+ * <tr>
+ * <th></th>
+ * <th>TimePeriod</th>
+ * <th>{@link Period}</th>
+ * <th>{@link Duration}</th>
+ * </tr>
+ * <tr>
+ * <td>Precision</td>
+ * <td>milliseconds</td>
+ * <td>days</td>
+ * <td>nanoseconds</td>
+ * </tr>
+ * <tr>
+ * <td>Time range (approx.)</td>
+ * <td>0 - 49 days</td>
+ * <td>unsupported</td>
+ * <td>-68 - 68 years</td>
+ * </tr>
+ * <tr>
+ * <td>Date range (approx.)</td>
+ * <td>0 to 370 million years</td>
+ * <td>-2.3 to 2.3 billion years</td>
+ * <td>unsupported</td>
+ * </tr>
+ * </table>
+ *
+ * @see <a href=
+ *      "https://avro.apache.org/docs/1.11.1/specification/#duration";>Avro 1.11
+ *      specification on duration</a>
+ */
+public final class TimePeriod implements TemporalAmount, Serializable {
+  private static final long MAX_UNSIGNED_INT = 0xffffffffL;
+  private static final long MONTHS_PER_YEAR = 12;
+  private static final long MONTHS_PER_DECADE = MONTHS_PER_YEAR * 10;
+  private static final long MONTHS_PER_CENTURY = MONTHS_PER_DECADE * 10;
+  private static final long MONTHS_PER_MILLENNIUM = MONTHS_PER_CENTURY * 10;
+  private static final long MILLIS_PER_SECOND = 1_000;
+  private static final long MILLIS_PER_MINUTE = MILLIS_PER_SECOND * 60;
+  private static final long MILLIS_PER_HOUR = MILLIS_PER_MINUTE * 60;
+  private static final long MILLIS_IN_HALF_DAY = MILLIS_PER_HOUR * 12;
+  private static final long MICROS_PER_MILLI = 1_000;
+  private static final long NANOS_PER_MILLI = 1_000_000;
+
+  private final long months;
+  private final long days;
+  private final long millis;
+
+  /**
+   * Create a TimePeriod from another TemporalAmount, such as a {@link Period} 
or
+   * a {@link Duration}.
+   *
+   * @param amount a temporal amount
+   * @return the corresponding TimePeriod
+   */
+  public static TimePeriod from(TemporalAmount amount) {
+    if (requireNonNull(amount, "amount") instanceof TimePeriod) {
+      return (TimePeriod) amount;
+    }
+    if (amount instanceof ChronoPeriod) {
+      if (!IsoChronology.INSTANCE.equals(((ChronoPeriod) 
amount).getChronology())) {
+        throw new DateTimeException("TimePeriod requires ISO chronology: " + 
amount);
+      }
+    }
+    long months = 0;
+    long days = 0;
+    long millis = 0;
+    for (TemporalUnit unit : amount.getUnits()) {
+      if (unit instanceof ChronoUnit) {
+        long unitAmount = amount.get(unit);
+        switch ((ChronoUnit) unit) {
+        case MILLENNIA:
+          months = unsignedInt(months + unitAmount * MONTHS_PER_MILLENNIUM);
+          break;
+        case CENTURIES:
+          months = unsignedInt(months + unitAmount * MONTHS_PER_CENTURY);
+          break;
+        case DECADES:
+          months = unsignedInt(months + unitAmount * MONTHS_PER_DECADE);
+          break;
+        case YEARS:
+          months = unsignedInt(months + unitAmount * MONTHS_PER_YEAR);
+          break;
+        case MONTHS:
+          months = unsignedInt(months + unitAmount);
+          break;
+        case WEEKS:
+          days = unsignedInt(days + unitAmount * 7);
+          break;
+        case DAYS:
+          days = unsignedInt(days + unitAmount);
+          break;
+        case HALF_DAYS:
+          days = unsignedInt(days + (unitAmount / 2)); // Truncates halves
+          if (unitAmount % 2 != 0) {
+            millis = unsignedInt(millis + MILLIS_IN_HALF_DAY);
+          }
+          break;
+        case HOURS:
+          millis = unsignedInt(millis + unitAmount * MILLIS_PER_HOUR);
+          break;
+        case MINUTES:
+          millis = unsignedInt(millis + unitAmount * MILLIS_PER_MINUTE);
+          break;
+        case SECONDS:
+          millis = unsignedInt(millis + unitAmount * MILLIS_PER_SECOND);
+          break;
+        case MILLIS:
+          millis = unsignedInt(millis + unitAmount);
+          break;
+        case MICROS:
+          if (unitAmount % MICROS_PER_MILLI != 0) {
+            throw new DateTimeException(
+                "Cannot add " + unitAmount + " microseconds: not a whole 
number of milliseconds");
+          }
+          millis = unsignedInt(millis + unitAmount / MICROS_PER_MILLI);
+          break;
+        case NANOS:
+          if (unitAmount % NANOS_PER_MILLI != 0) {
+            throw new DateTimeException(
+                "Cannot add " + unitAmount + " nanoseconds: not a whole number 
of milliseconds");
+          }
+          millis = unsignedInt(millis + unitAmount / NANOS_PER_MILLI);
+          break;
+        default:
+          throw new UnsupportedTemporalTypeException("Unsupported unit: " + 
unit);
+        }
+      } else {
+        throw new UnsupportedTemporalTypeException("Unsupported unit: " + 
unit);
+      }
+    }
+    return new TimePeriod(months, days, millis);
+  }
+
+  /**
+   * Create a TimePeriod from a number of months, days and milliseconds
+   *
+   * @param months a number of months
+   * @param days   a number of days
+   * @param millis a number of milliseconds
+   * @return the corresponding TimePeriod
+   * @throws ArithmeticException if any of the parameters does not fit an 
unsigned
+   *                             long (0..4294967296)
+   */
+  public static TimePeriod of(long months, long days, long millis) {
+    return new TimePeriod(unsignedInt(months), unsignedInt(days), 
unsignedInt(millis));
+  }
+
+  private static long unsignedInt(long number) {
+    if (number != (number & MAX_UNSIGNED_INT)) {
+      throw new ArithmeticException("Overflow/underflow of unsigned int");
+    }
+    return number;
+  }
+
+  private TimePeriod(long months, long days, long millis) {
+    this.months = months;
+    this.days = days;
+    this.millis = millis;
+  }
+
+  public Duration toDuration() {
+    return Duration.from(this);
+  }
+
+  public Period toPeriod() {
+    if (isDateBased()) {
+      // We use unsigned ints, which have double the range of a signed int that
+      // Period uses. We can split months to years and months to ensure 
there's no
+      // overflow. But we cannot split days, as both days and months have 
varying
+      // lengths.
+      int yearsAsInt = (int) (months / MONTHS_PER_YEAR);
+      int monthsAsInt = (int) (months % MONTHS_PER_YEAR);
+      int daysAsInt = (int) days;
+      if (days != daysAsInt) {
+        throw new DateTimeException("Too many days: a Period can contain at 
most " + Integer.MAX_VALUE + " days.");
+      }
+      return 
Period.ofYears(yearsAsInt).withMonths(monthsAsInt).withDays(daysAsInt);
+    }
+    throw new DateTimeException("Cannot convert this TimePeriod to a Period: 
is not date based");
+  }
+
+  /**
+   * Determines if the TimePeriod is date based (i.e., if its milliseconds
+   * component is 0).
+   *
+   * @return {@code true} iff the TimePeriod is date based
+   */
+  public boolean isDateBased() {
+    return millis == 0;
+  }
+
+  /**
+   * Determines if the TimePeriod is time based (i.e., if its months and days
+   * components are 0).
+   *
+   * @return {@code true} iff the TimePeriod is time based
+   */
+  public boolean isTimeBased() {
+    return months == 0 && days == 0;
+  }
+
+  public long getMonths() {
+    return months;
+  }
+
+  public long getDays() {
+    return days;
+  }
+
+  public long getMillis() {
+    return millis;
+  }
+
+  @Override
+  public long get(TemporalUnit unit) {
+    if (unit == MONTHS) {
+      return months;
+    } else if (unit == DAYS) {
+      return days;
+    } else if (unit == MILLIS) {
+      return millis;
+    } else {
+      throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit);
+    }
+  }
+
+  @Override
+  public List<TemporalUnit> getUnits() {
+    List<TemporalUnit> units = new ArrayList<>();
+    // The zero-checks ensure compatibility with the Java Time classes Period 
and
+    // Duration where possible.
+    if (months != 0) {
+      units.add(MONTHS);
+    }
+    if (days != 0) {
+      units.add(DAYS);
+    }
+    if (millis != 0) {
+      units.add(MILLIS);
+    }
+    return unmodifiableList(units);
+  }
+
+  @Override
+  public Temporal addTo(Temporal temporal) {
+    return addTo(temporal, months, days, millis);
+  }
+
+  @Override
+  public Temporal subtractFrom(Temporal temporal) {
+    return addTo(temporal, -months, -days, -millis);
+  }
+
+  private Temporal addTo(Temporal temporal, long months, long days, long 
millis) {
+    // The zero-checks ensure we can add a TimePeriod to a Temporal even when 
it
+    // does not support all fields, as long as the unsupported fields are zero.
+    if (months != 0) {
+      temporal = temporal.plus(months, MONTHS);
+    }
+    if (days != 0) {
+      temporal = temporal.plus(days, DAYS);
+    }
+    if (millis != 0) {
+      temporal = temporal.plus(millis, MILLIS);
+    }
+    return temporal;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    TimePeriod that = (TimePeriod) o;
+    return months == that.months && days == that.days && millis == that.millis;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(months, days, millis);
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder buffer = new StringBuilder();
+    buffer.append("P");
+    if (months != 0) {
+      int years = (int) (months / MONTHS_PER_YEAR);
+      int monthsLeft = (int) (months % MONTHS_PER_YEAR);
+      if (years != 0) {
+        buffer.append(years).append("Y");
+      }
+      if (monthsLeft != 0) {
+        buffer.append(monthsLeft).append("M");
+      }
+    }
+    if (days != 0 || (months == 0 && millis == 0)) {
+      buffer.append(days);
+    }
+    if (millis != 0) {
+      long millisLeft = millis;
+      int hours = (int) (millisLeft / MILLIS_PER_HOUR);
+      millisLeft -= MILLIS_PER_HOUR * hours;
+      int minutes = (int) (millisLeft / MILLIS_PER_MINUTE);
+      millisLeft -= MILLIS_PER_MINUTE * minutes;
+      int seconds = (int) (millisLeft / MILLIS_PER_SECOND);
+      millisLeft -= MILLIS_PER_SECOND * seconds;
+      if (millisLeft != 0) {
+        buffer.append(String.format("T%02d:%02d:%02d.%03d", hours, minutes, 
seconds, millisLeft));
+      } else if (seconds != 0) {
+        buffer.append(String.format("T%02d:%02d:%02d", hours, minutes, 
seconds));
+      } else if (minutes != 0) {
+        buffer.append(String.format("T%02d:%02d", hours, minutes));
+      } else {
+        buffer.append(String.format("T%02d", hours));
+      }
+    }
+    return buffer.toString();
+  }
+}
diff --git a/lang/java/avro/src/test/java/org/apache/avro/TestLogicalType.java 
b/lang/java/avro/src/test/java/org/apache/avro/TestLogicalType.java
index acc8899b2..4476ac7db 100644
--- a/lang/java/avro/src/test/java/org/apache/avro/TestLogicalType.java
+++ b/lang/java/avro/src/test/java/org/apache/avro/TestLogicalType.java
@@ -18,16 +18,21 @@
 
 package org.apache.avro;
 
-import java.util.Arrays;
-import java.util.concurrent.Callable;
-
 import org.hamcrest.collection.IsMapContaining;
 import org.junit.jupiter.api.Test;
 
+import java.util.Arrays;
+import java.util.concurrent.Callable;
+
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.instanceOf;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
 
 public class TestLogicalType {
 
@@ -197,6 +202,30 @@ public class TestLogicalType {
     assertEquals(schema, parsed, "Constructed and parsed schemas should 
match");
   }
 
+  @Test
+  void uuidExtendsString() {
+    Schema uuidSchema = 
LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.STRING));
+    assertEquals(LogicalTypes.uuid(), uuidSchema.getLogicalType());
+
+    assertThrows("UUID requires a string", IllegalArgumentException.class,
+        "Uuid can only be used with an underlying string type",
+        () -> LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.INT)));
+  }
+
+  @Test
+  void durationExtendsFixed12() {
+    Schema durationSchema = 
LogicalTypes.duration().addToSchema(Schema.createFixed("f", null, null, 12));
+    assertEquals(LogicalTypes.duration(), durationSchema.getLogicalType());
+
+    assertThrows("Duration requires a fixed(12)", 
IllegalArgumentException.class,
+        "Duration can only be used with an underlying fixed type of size 12.",
+        () -> 
LogicalTypes.duration().addToSchema(Schema.create(Schema.Type.INT)));
+
+    assertThrows("Duration requires a fixed(12)", 
IllegalArgumentException.class,
+        "Duration can only be used with an underlying fixed type of size 12.",
+        () -> LogicalTypes.duration().addToSchema(Schema.createFixed("wrong", 
null, null, 42)));
+  }
+
   @Test
   void logicalTypeEquals() {
     LogicalTypes.Decimal decimal90 = LogicalTypes.decimal(9);
diff --git 
a/lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericLogicalTypes.java
 
b/lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericLogicalTypes.java
index 25a838db3..6df4a8af6 100644
--- 
a/lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericLogicalTypes.java
+++ 
b/lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericLogicalTypes.java
@@ -18,19 +18,6 @@
 
 package org.apache.avro.generic;
 
-import java.io.File;
-import java.io.IOException;
-import java.math.BigDecimal;
-import java.nio.ByteBuffer;
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.ZoneOffset;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.UUID;
-
 import org.apache.avro.Conversion;
 import org.apache.avro.Conversions;
 import org.apache.avro.CustomType;
@@ -43,11 +30,26 @@ import org.apache.avro.file.DataFileWriter;
 import org.apache.avro.file.FileReader;
 import org.apache.avro.io.DatumReader;
 import org.apache.avro.io.DatumWriter;
-
+import org.apache.avro.util.TimePeriod;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
+import java.io.File;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.is;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -64,6 +66,7 @@ public class TestGenericLogicalTypes {
   public static void addLogicalTypes() {
     GENERIC.addLogicalTypeConversion(new Conversions.DecimalConversion());
     GENERIC.addLogicalTypeConversion(new Conversions.UUIDConversion());
+    GENERIC.addLogicalTypeConversion(new Conversions.DurationConversion());
     GENERIC.addLogicalTypeConversion(new 
TimeConversions.LocalTimestampMicrosConversion());
     GENERIC.addLogicalTypeConversion(new 
TimeConversions.LocalTimestampMillisConversion());
   }
@@ -116,6 +119,37 @@ public class TestGenericLogicalTypes {
         "Should read UUIDs as Strings");
   }
 
+  @Test
+  public void readWriteDuration() throws IOException {
+    Schema fixedSchema = Schema.createFixed("bare.Fixed", null, null, 12);
+
+    Schema durationSchema = Schema.createFixed("time.Duration", null, null, 
12);
+    LogicalTypes.duration().addToSchema(durationSchema);
+
+    // These two are necessary for schema evolution!
+    fixedSchema.addAlias(durationSchema.getFullName());
+    durationSchema.addAlias(fixedSchema.getFullName());
+
+    Random rng = new Random();
+    TimePeriod d1 = TimePeriod.of(rng.nextInt(1000), rng.nextInt(1000), 
rng.nextInt(1000));
+    ByteBuffer b1 = 
ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN).putInt((int) 
d1.getMonths())
+        .putInt((int) d1.getDays()).putInt((int) d1.getMillis());
+    GenericFixed f1 = new GenericData.Fixed(fixedSchema, b1.array());
+
+    TimePeriod d2 = TimePeriod.of(rng.nextInt(1000), rng.nextInt(1000), 
rng.nextInt(1000));
+    ByteBuffer b2 = 
ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN).putInt((int) 
d2.getMonths())
+        .putInt((int) d2.getDays()).putInt((int) d2.getMillis());
+    GenericFixed f2 = new GenericData.Fixed(fixedSchema, b2.array());
+
+    File test = write(fixedSchema, f1, f2);
+    assertEquals(Arrays.asList(d1, d2), 
read(GENERIC.createDatumReader(durationSchema), test),
+        "Should convert fixed bytes to durations");
+
+    test = write(GENERIC, durationSchema, d2, d1);
+    assertEquals(Arrays.asList(f2, f1), 
read(GenericData.get().createDatumReader(fixedSchema), test),
+        "Should convert durations to fixed bytes");
+  }
+
   @Test
   public void readDecimalFixed() throws IOException {
     LogicalType decimal = LogicalTypes.decimal(9, 2);
@@ -157,11 +191,11 @@ public class TestGenericLogicalTypes {
   }
 
   @Test
-  public void decimalToFromBytes() throws IOException {
+  public void decimalToFromBytes() {
     LogicalType decimal = LogicalTypes.decimal(9, 2);
     Schema bytesSchema = Schema.create(Schema.Type.BYTES);
 
-    // Check that the round trip to and from bytes
+    // Check the round trip to and from bytes
     BigDecimal d1 = new BigDecimal("-34.34");
     BigDecimal d2 = new BigDecimal("117230.00");
 
@@ -178,11 +212,11 @@ public class TestGenericLogicalTypes {
   }
 
   @Test
-  public void decimalToFromFixed() throws IOException {
+  public void decimalToFromFixed() {
     LogicalType decimal = LogicalTypes.decimal(9, 2);
     Schema fixedSchema = Schema.createFixed("aFixed", null, null, 4);
 
-    // Check that the round trip to and from fixed data.
+    // Check the round trip to and from fixed data.
     BigDecimal d1 = new BigDecimal("-34.34");
     BigDecimal d2 = new BigDecimal("117230.00");
 
@@ -247,7 +281,8 @@ public class TestGenericLogicalTypes {
     return data;
   }
 
-  private <D> File write(Schema schema, D... data) throws IOException {
+  @SafeVarargs
+  private final <D> File write(Schema schema, D... data) throws IOException {
     return write(GenericData.get(), schema, data);
   }
 
@@ -308,12 +343,12 @@ public class TestGenericLogicalTypes {
 
     // test nested in array
     Schema arraySchema = Schema.createArray(schema);
-    ArrayList array = new ArrayList(Collections.singletonList(value));
+    ArrayList<Object> array = new 
ArrayList<>(Collections.singletonList(value));
     checkCopy(array, model.deepCopy(arraySchema, array), true);
 
     // test record nested in array
     Schema recordArraySchema = Schema.createArray(recordSchema);
-    ArrayList recordArray = new ArrayList(Collections.singletonList(record));
+    ArrayList<GenericRecord> recordArray = new 
ArrayList<>(Collections.singletonList(record));
     checkCopy(recordArray, model.deepCopy(recordArraySchema, recordArray), 
true);
   }
 
@@ -329,7 +364,7 @@ public class TestGenericLogicalTypes {
     Schema longSchema = Schema.create(Schema.Type.LONG);
     Schema timestampSchema = 
timestamp.addToSchema(Schema.create(Schema.Type.LONG));
 
-    LocalDateTime i1 = LocalDateTime.of(1986, 06, 26, 12, 07, 11, 42000000);
+    LocalDateTime i1 = LocalDateTime.of(1986, 6, 26, 12, 7, 11, 42000000);
     LocalDateTime i2 = LocalDateTime.ofInstant(Instant.ofEpochMilli(0), 
ZoneOffset.UTC);
     List<LocalDateTime> expected = Arrays.asList(i1, i2);
 
@@ -350,7 +385,7 @@ public class TestGenericLogicalTypes {
     Schema longSchema = Schema.create(Schema.Type.LONG);
     Schema timestampSchema = 
timestamp.addToSchema(Schema.create(Schema.Type.LONG));
 
-    LocalDateTime i1 = LocalDateTime.of(1986, 06, 26, 12, 07, 11, 42000000);
+    LocalDateTime i1 = LocalDateTime.of(1986, 6, 26, 12, 7, 11, 42000000);
     LocalDateTime i2 = LocalDateTime.ofInstant(Instant.ofEpochMilli(0), 
ZoneOffset.UTC);
 
     Conversion<LocalDateTime> conversion = new 
TimeConversions.LocalTimestampMillisConversion();
@@ -370,7 +405,7 @@ public class TestGenericLogicalTypes {
     Schema longSchema = Schema.create(Schema.Type.LONG);
     Schema timestampSchema = 
timestamp.addToSchema(Schema.create(Schema.Type.LONG));
 
-    LocalDateTime i1 = LocalDateTime.of(1986, 06, 26, 12, 07, 11, 420000);
+    LocalDateTime i1 = LocalDateTime.of(1986, 6, 26, 12, 7, 11, 420000);
     LocalDateTime i2 = LocalDateTime.ofInstant(Instant.ofEpochSecond(0, 4000), 
ZoneOffset.UTC);
     List<LocalDateTime> expected = Arrays.asList(i1, i2);
 
@@ -391,7 +426,7 @@ public class TestGenericLogicalTypes {
     Schema longSchema = Schema.create(Schema.Type.LONG);
     Schema timestampSchema = 
timestamp.addToSchema(Schema.create(Schema.Type.LONG));
 
-    LocalDateTime i1 = LocalDateTime.of(1986, 06, 26, 12, 07, 11, 420000);
+    LocalDateTime i1 = LocalDateTime.of(1986, 6, 26, 12, 7, 11, 420000);
     LocalDateTime i2 = LocalDateTime.ofInstant(Instant.ofEpochSecond(0, 4000), 
ZoneOffset.UTC);
 
     Conversion<LocalDateTime> conversion = new 
TimeConversions.LocalTimestampMicrosConversion();
diff --git 
a/lang/java/avro/src/test/java/org/apache/avro/util/TimePeriodTest.java 
b/lang/java/avro/src/test/java/org/apache/avro/util/TimePeriodTest.java
new file mode 100644
index 000000000..cd9809be4
--- /dev/null
+++ b/lang/java/avro/src/test/java/org/apache/avro/util/TimePeriodTest.java
@@ -0,0 +1,306 @@
+/*
+ * 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
+ *
+ *     https://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.avro.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.Period;
+import java.time.chrono.IsoChronology;
+import java.time.chrono.JapaneseChronology;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.Temporal;
+import java.time.temporal.TemporalAmount;
+import java.time.temporal.TemporalUnit;
+import java.time.temporal.UnsupportedTemporalTypeException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static java.time.temporal.ChronoUnit.DAYS;
+import static java.time.temporal.ChronoUnit.ERAS;
+import static java.time.temporal.ChronoUnit.MICROS;
+import static java.time.temporal.ChronoUnit.MILLIS;
+import static java.time.temporal.ChronoUnit.MONTHS;
+import static java.time.temporal.ChronoUnit.NANOS;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TimePeriodTest {
+  // This Long is too large to fit into an unsigned int.
+  private static final long TOO_LARGE = Integer.MAX_VALUE * 3L;
+
+  @Test
+  void validateConstruction() {
+    TimePeriod timePeriod = TimePeriod.of(12, 34, 56);
+    assertSame(timePeriod, TimePeriod.from(timePeriod));
+    assertComponents(12, 34, 56, timePeriod);
+
+    assertComponents(14, 3, 0, 
TimePeriod.from(IsoChronology.INSTANCE.period(1, 2, 3)));
+
+    assertComponents(36_000, 0, 0, 
TimePeriod.from(TimeAmount.of(ChronoUnit.MILLENNIA, 3)));
+    assertComponents(3_600, 0, 0, 
TimePeriod.from(TimeAmount.of(ChronoUnit.CENTURIES, 3)));
+    assertComponents(360, 0, 0, 
TimePeriod.from(TimeAmount.of(ChronoUnit.DECADES, 3)));
+    assertComponents(36, 0, 0, TimePeriod.from(TimeAmount.of(ChronoUnit.YEARS, 
3)));
+    assertComponents(3, 0, 0, TimePeriod.from(TimeAmount.of(MONTHS, 3)));
+
+    assertComponents(0, 21, 0, TimePeriod.from(TimeAmount.of(ChronoUnit.WEEKS, 
3)));
+    assertComponents(0, 3, 0, TimePeriod.from(TimeAmount.of(DAYS, 3)));
+    assertComponents(0, 2, 0, 
TimePeriod.from(TimeAmount.of(ChronoUnit.HALF_DAYS, 4)));
+    assertComponents(0, 2, 43_200_000, 
TimePeriod.from(TimeAmount.of(ChronoUnit.HALF_DAYS, 5)));
+
+    assertComponents(0, 0, 10_800_000, 
TimePeriod.from(TimeAmount.of(ChronoUnit.HOURS, 3)));
+    assertComponents(0, 0, 180_000, 
TimePeriod.from(TimeAmount.of(ChronoUnit.MINUTES, 3)));
+    assertComponents(0, 0, 3_000, 
TimePeriod.from(TimeAmount.of(ChronoUnit.SECONDS, 3)));
+    assertComponents(0, 0, 3, TimePeriod.from(TimeAmount.of(MILLIS, 3)));
+    assertComponents(0, 0, 3, TimePeriod.from(TimeAmount.of(MICROS, 3_000)));
+    assertComponents(0, 0, 3, TimePeriod.from(TimeAmount.of(NANOS, 
3_000_000)));
+
+    // Micros and nanos must be a multiple of milliseconds
+    assertThrows(DateTimeException.class, () -> 
TimePeriod.from(TimeAmount.of(ChronoUnit.MICROS, 3)));
+    assertThrows(DateTimeException.class, () -> 
TimePeriod.from(TimeAmount.of(ChronoUnit.NANOS, 3)));
+    // Unsupported cases (null, non-ISO chronology, unknown temporal unit,
+    // non-ChronoUnit)
+    assertThrows(NullPointerException.class, () -> TimePeriod.from(null));
+    assertThrows(DateTimeException.class, () -> 
TimePeriod.from(JapaneseChronology.INSTANCE.period(1, 2, 3)));
+    assertThrows(UnsupportedTemporalTypeException.class, () -> 
TimePeriod.from(TimeAmount.of(ChronoUnit.ERAS, 1)));
+    assertThrows(UnsupportedTemporalTypeException.class, () -> 
TimePeriod.from(TimeAmount.of(DummyUnit.INSTANCE, 3)));
+    // Arguments are long, but must fit an unsigned long
+    assertThrows(ArithmeticException.class, () -> TimePeriod.of(TOO_LARGE, 0, 
0));
+    assertThrows(ArithmeticException.class, () -> TimePeriod.of(0, TOO_LARGE, 
0));
+    assertThrows(ArithmeticException.class, () -> TimePeriod.of(0, 0, 
TOO_LARGE));
+
+    // Odd one out: querying an unsupported temporal unit
+    // (assertComponents handles all valid cases)
+    assertThrows(UnsupportedTemporalTypeException.class, () -> 
TimePeriod.of(1, 1, 1).get(ERAS));
+  }
+
+  @Test
+  void checkConversionsFromJavaTime() {
+    assertEquals(TimePeriod.of(12, 0, 0), TimePeriod.from(Period.ofYears(1)));
+    assertEquals(TimePeriod.of(2, 0, 0), TimePeriod.from(Period.ofMonths(2)));
+    assertEquals(TimePeriod.of(0, 21, 0), TimePeriod.from(Period.ofWeeks(3)));
+    assertEquals(TimePeriod.of(0, 4, 0), TimePeriod.from(Period.ofDays(4)));
+
+    assertEquals(TimePeriod.of(0, 0, 1), 
TimePeriod.from(Duration.ofNanos(1_000_000)));
+    assertEquals(TimePeriod.of(0, 0, 2), 
TimePeriod.from(Duration.ofMillis(2)));
+    assertEquals(TimePeriod.of(0, 0, 3_000), 
TimePeriod.from(Duration.ofSeconds(3)));
+    assertEquals(TimePeriod.of(0, 0, 240000), 
TimePeriod.from(Duration.ofMinutes(4)));
+    assertEquals(TimePeriod.of(0, 0, 18000000), 
TimePeriod.from(Duration.ofHours(5)));
+    // Duration never takes into account things like daylight saving
+    assertEquals(TimePeriod.of(0, 0, 518400000), 
TimePeriod.from(Duration.ofDays(6)));
+  }
+
+  @Test
+  void checkConversionsToJavaTime() {
+    TimePeriod months = TimePeriod.of(1, 0, 0);
+    TimePeriod days = TimePeriod.of(0, 2, 0);
+    TimePeriod time = TimePeriod.of(0, 0, 3);
+    TimePeriod all = TimePeriod.of(1, 2, 3);
+
+    assertTrue(months.isDateBased());
+    assertTrue(days.isDateBased());
+    assertFalse(all.isDateBased());
+    assertFalse(time.isDateBased());
+
+    assertEquals(Period.of(0, 1, 0), months.toPeriod());
+    assertEquals(Period.of(0, 0, 2), days.toPeriod());
+    assertThrows(DateTimeException.class, all::toPeriod);
+    assertThrows(DateTimeException.class, time::toPeriod);
+
+    assertThrows(DateTimeException.class, () -> TimePeriod.of(0, 
Integer.MAX_VALUE * 2L, 0).toPeriod());
+
+    assertFalse(months.isTimeBased());
+    assertFalse(days.isTimeBased());
+    assertFalse(all.isTimeBased());
+    assertTrue(time.isTimeBased());
+
+    assertThrows(DateTimeException.class, months::toDuration);
+    // Note: though Duration supports this, it uses a fixed 86400 seconds
+    assertEquals(Duration.ofSeconds(172800), days.toDuration());
+    assertThrows(DateTimeException.class, all::toDuration);
+    assertEquals(Duration.ofMillis(3), time.toDuration());
+  }
+
+  @Test
+  void checkAddingToTemporalItems() {
+    TimePeriod monthAndTwoDays = TimePeriod.of(1, 2, 0);
+    TimePeriod threeMillis = TimePeriod.of(0, 0, 3);
+    TimePeriod complexTimePeriod = TimePeriod.of(1, 2, 3);
+
+    LocalDateTime localDateTime = LocalDateTime.of(2001, 2, 3, 4, 5, 6, 
7_000_000);
+    LocalDate localDate = LocalDate.of(2001, 2, 3);
+    LocalTime localTime = LocalTime.of(4, 5, 6, 7_000_000);
+
+    assertEquals(localDateTime.plusMonths(1).plusDays(2), 
localDateTime.plus(monthAndTwoDays));
+    assertEquals(localDateTime.plus(3, MILLIS), 
localDateTime.plus(threeMillis));
+    assertEquals(localDateTime.plusMonths(1).plusDays(2).plus(3, MILLIS), 
localDateTime.plus(complexTimePeriod));
+
+    assertEquals(localDate.plusMonths(1).plusDays(2), 
localDate.plus(monthAndTwoDays));
+
+    assertEquals(localTime.plus(3, MILLIS), localTime.plus(threeMillis));
+
+    assertEquals(localDateTime.minusMonths(1).minusDays(2), 
localDateTime.minus(monthAndTwoDays));
+    assertEquals(localDateTime.minus(3, MILLIS), 
localDateTime.minus(threeMillis));
+    assertEquals(localDateTime.minusMonths(1).minusDays(2).minus(3, MILLIS), 
localDateTime.minus(complexTimePeriod));
+
+    assertEquals(localDate.minusMonths(1).minusDays(2), 
localDate.minus(monthAndTwoDays));
+
+    assertEquals(localTime.minus(3, MILLIS), localTime.minus(threeMillis));
+  }
+
+  @Test
+  void checkEqualityTests() {
+    TimePeriod timePeriod1a = TimePeriod.of(1, 2, 3);
+    TimePeriod timePeriod1b = TimePeriod.of(1, 2, 3);
+    TimePeriod timePeriod2 = TimePeriod.of(9, 9, 9);
+    TimePeriod timePeriod3 = TimePeriod.of(1, 9, 9);
+    TimePeriod timePeriod4 = TimePeriod.of(1, 2, 9);
+
+    // noinspection EqualsWithItself
+    assertEquals(timePeriod1a, timePeriod1a);
+    assertEquals(timePeriod1a, timePeriod1b);
+    assertEquals(timePeriod1a.hashCode(), timePeriod1b.hashCode());
+
+    assertNotEquals(timePeriod1a, null);
+    // noinspection AssertBetweenInconvertibleTypes
+    assertNotEquals(timePeriod1a, "not equal");
+    assertNotEquals(timePeriod1a, timePeriod2);
+    assertNotEquals(timePeriod1a.hashCode(), timePeriod2.hashCode());
+    assertNotEquals(timePeriod1a, timePeriod3);
+    assertNotEquals(timePeriod1a.hashCode(), timePeriod3.hashCode());
+    assertNotEquals(timePeriod1a, timePeriod4);
+    assertNotEquals(timePeriod1a.hashCode(), timePeriod4.hashCode());
+  }
+
+  @Test
+  void checkStringRepresentation() {
+    assertEquals("P0", TimePeriod.of(0, 0, 0).toString());
+    assertEquals("P1Y", TimePeriod.of(12, 0, 0).toString());
+    assertEquals("P2M", TimePeriod.of(2, 0, 0).toString());
+    assertEquals("P3", TimePeriod.of(0, 3, 0).toString());
+    assertEquals("P1Y2M3", TimePeriod.of(14, 3, 0).toString());
+    assertEquals("PT04", TimePeriod.of(0, 0, 14400000).toString());
+    assertEquals("PT00:05", TimePeriod.of(0, 0, 300000).toString());
+    assertEquals("PT00:00:06", TimePeriod.of(0, 0, 6000).toString());
+    assertEquals("PT00:00:00.007", TimePeriod.of(0, 0, 7).toString());
+    assertEquals("P1Y2M3T04:05:06.007", TimePeriod.of(14, 3, 
14706007).toString());
+
+    // Days and millis will never overflow to months/days, to respect 
differences
+    // in months and days (daylight saving).
+    assertEquals("P123T1193:02:47.295", TimePeriod.of(0, 123, 
4294967295L).toString());
+  }
+
+  private void assertComponents(long months, long days, long millis, 
TimePeriod timePeriod) {
+    List<TemporalUnit> expectedUnits = new ArrayList<>(Arrays.asList(MONTHS, 
DAYS, MILLIS));
+    if (months == 0) {
+      expectedUnits.remove(MONTHS);
+    }
+    if (days == 0) {
+      expectedUnits.remove(DAYS);
+    }
+    if (millis == 0) {
+      expectedUnits.remove(MILLIS);
+    }
+    assertEquals(expectedUnits, timePeriod.getUnits());
+
+    assertEquals(months, timePeriod.getMonths());
+    assertEquals(months, timePeriod.get(MONTHS));
+    assertEquals(days, timePeriod.getDays());
+    assertEquals(days, timePeriod.get(DAYS));
+    assertEquals(millis, timePeriod.getMillis());
+    assertEquals(millis, timePeriod.get(MILLIS));
+  }
+
+  private static class TimeAmount implements TemporalAmount {
+    private final Map<TemporalUnit, Long> amountsPerUnit = new 
LinkedHashMap<>();
+
+    static TimeAmount of(TemporalUnit unit, long amount) {
+      return new TimeAmount().with(unit, amount);
+    }
+
+    TimeAmount with(TemporalUnit unit, long amount) {
+      amountsPerUnit.put(unit, amount);
+      return this;
+    }
+
+    @Override
+    public long get(TemporalUnit unit) {
+      return amountsPerUnit.get(unit);
+    }
+
+    @Override
+    public List<TemporalUnit> getUnits() {
+      return new ArrayList<>(amountsPerUnit.keySet());
+    }
+
+    @Override
+    public Temporal addTo(Temporal temporal) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Temporal subtractFrom(Temporal temporal) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private static class DummyUnit implements TemporalUnit {
+    private static final DummyUnit INSTANCE = new DummyUnit();
+
+    @Override
+    public Duration getDuration() {
+      return null;
+    }
+
+    @Override
+    public boolean isDurationEstimated() {
+      return false;
+    }
+
+    @Override
+    public boolean isDateBased() {
+      return false;
+    }
+
+    @Override
+    public boolean isTimeBased() {
+      return false;
+    }
+
+    @Override
+    public <R extends Temporal> R addTo(R temporal, long amount) {
+      return null;
+    }
+
+    @Override
+    public long between(Temporal temporal1Inclusive, Temporal 
temporal2Exclusive) {
+      return 0;
+    }
+  }
+}


Reply via email to