Repository: google/gson (Java)
Date: 2022-10-02 18:09:15+00:00
Task type: Bug Fix

Issue:
Excluder rejects field when version exceeds or is equal to version in @Until
Based on [this Stack Overflow question](http://stackoverflow.com/questions/41962820/until-annotation-in-gson-not-working-as-expected).

[`@Until`](http://static.javadoc.io/com.google.code.gson/gson/2.8.0/com/google/gson/annotations/Until.html) is specified as

> Basically, if `Gson` is created with a version number that **exceeds** the value stored in the `Until` annotation then the field will be ignored from the JSON output.

However, the [`Excluder` that implements](
https://github.com/google/gson/blob/c935f89b23c69e7c9d6d98df87f81635b3eb3700/gson/src/main/java/com/google/gson/internal/Excluder.java#L245) this logic rejects if a field if the version number exceeds **or is equal to** the version in `@Until`. 


Issue discussion:
It happened to me as well... 

Solution patch:
diff --git a/gson/src/main/java/com/google/gson/GsonBuilder.java b/gson/src/main/java/com/google/gson/GsonBuilder.java
--- a/gson/src/main/java/com/google/gson/GsonBuilder.java
+++ b/gson/src/main/java/com/google/gson/GsonBuilder.java
@@ -28,6 +28,8 @@
 import static com.google.gson.Gson.DEFAULT_SPECIALIZE_FLOAT_VALUES;
 import static com.google.gson.Gson.DEFAULT_USE_JDK_UNSAFE;
 
+import com.google.gson.annotations.Since;
+import com.google.gson.annotations.Until;
 import com.google.gson.internal.$Gson$Preconditions;
 import com.google.gson.internal.Excluder;
 import com.google.gson.internal.bind.DefaultDateTypeAdapter;
@@ -143,14 +145,25 @@ public GsonBuilder() {
   }
 
   /**
-   * Configures Gson to enable versioning support.
+   * Configures Gson to enable versioning support. Versioning support works based on the
+   * annotation types {@link Since} and {@link Until}. It allows including or excluding fields
+   * and classes based on the specified version. See the documentation of these annotation
+   * types for more information.
    *
-   * @param ignoreVersionsAfter any field or type marked with a version higher than this value
-   * are ignored during serialization or deserialization.
+   * <p>By default versioning support is disabled and usage of {@code @Since} and {@code @Until}
+   * has no effect.
+   *
+   * @param version the version number to use.
    * @return a reference to this {@code GsonBuilder} object to fulfill the "Builder" pattern
+   * @throws IllegalArgumentException if the version number is NaN or negative
+   * @see Since
+   * @see Until
    */
-  public GsonBuilder setVersion(double ignoreVersionsAfter) {
-    excluder = excluder.withVersion(ignoreVersionsAfter);
+  public GsonBuilder setVersion(double version) {
+    if (Double.isNaN(version) || version < 0.0) {
+      throw new IllegalArgumentException("Invalid version: " + version);
+    }
+    excluder = excluder.withVersion(version);
     return this;
   }
 
diff --git a/gson/src/main/java/com/google/gson/annotations/Since.java b/gson/src/main/java/com/google/gson/annotations/Since.java
--- a/gson/src/main/java/com/google/gson/annotations/Since.java
+++ b/gson/src/main/java/com/google/gson/annotations/Since.java
@@ -16,6 +16,7 @@
 
 package com.google.gson.annotations;
 
+import com.google.gson.GsonBuilder;
 import java.lang.annotation.Documented;
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
@@ -24,12 +25,11 @@
 
 /**
  * An annotation that indicates the version number since a member or a type has been present.
- * This annotation is useful to manage versioning of your Json classes for a web-service.
+ * This annotation is useful to manage versioning of your JSON classes for a web-service.
  *
  * <p>
  * This annotation has no effect unless you build {@link com.google.gson.Gson} with a
- * {@link com.google.gson.GsonBuilder} and invoke
- * {@link com.google.gson.GsonBuilder#setVersion(double)} method.
+ * {@code GsonBuilder} and invoke the {@link GsonBuilder#setVersion(double)} method.
  *
  * <p>Here is an example of how this annotation is meant to be used:</p>
  * <pre>
@@ -50,14 +50,16 @@
  *
  * @author Inderjeet Singh
  * @author Joel Leitch
+ * @see GsonBuilder#setVersion(double)
+ * @see Until
  */
 @Documented
 @Retention(RetentionPolicy.RUNTIME)
 @Target({ElementType.FIELD, ElementType.TYPE})
 public @interface Since {
   /**
-   * the value indicating a version number since this member
-   * or type has been present.
+   * The value indicating a version number since this member or type has been present.
+   * The number is inclusive; annotated elements will be included if {@code gsonVersion >= value}.
    */
   double value();
 }
diff --git a/gson/src/main/java/com/google/gson/annotations/Until.java b/gson/src/main/java/com/google/gson/annotations/Until.java
--- a/gson/src/main/java/com/google/gson/annotations/Until.java
+++ b/gson/src/main/java/com/google/gson/annotations/Until.java
@@ -16,6 +16,7 @@
 
 package com.google.gson.annotations;
 
+import com.google.gson.GsonBuilder;
 import java.lang.annotation.Documented;
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
@@ -24,14 +25,13 @@
 
 /**
  * An annotation that indicates the version number until a member or a type should be present.
- * Basically, if Gson is created with a version number that exceeds the value stored in the
- * {@code Until} annotation then the field will be ignored from the JSON output. This annotation
- * is useful to manage versioning of your JSON classes for a web-service.
+ * Basically, if Gson is created with a version number that is equal to or exceeds the value
+ * stored in the {@code Until} annotation then the field will be ignored from the JSON output.
+ * This annotation is useful to manage versioning of your JSON classes for a web-service.
  *
  * <p>
  * This annotation has no effect unless you build {@link com.google.gson.Gson} with a
- * {@link com.google.gson.GsonBuilder} and invoke
- * {@link com.google.gson.GsonBuilder#setVersion(double)} method.
+ * {@code GsonBuilder} and invoke the {@link GsonBuilder#setVersion(double)} method.
  *
  * <p>Here is an example of how this annotation is meant to be used:</p>
  * <pre>
@@ -47,12 +47,14 @@
  * methods will use all the fields for serialization and deserialization. However, if you created
  * Gson with {@code Gson gson = new GsonBuilder().setVersion(1.2).create()} then the
  * {@code toJson()} and {@code fromJson()} methods of Gson will exclude the {@code emailAddress}
- * and {@code password} fields from the example above, because the version number passed to the 
+ * and {@code password} fields from the example above, because the version number passed to the
  * GsonBuilder, {@code 1.2}, exceeds the version number set on the {@code Until} annotation,
  * {@code 1.1}, for those fields.
  *
  * @author Inderjeet Singh
  * @author Joel Leitch
+ * @see GsonBuilder#setVersion(double)
+ * @see Since
  * @since 1.3
  */
 @Documented
@@ -61,8 +63,8 @@
 public @interface Until {
 
   /**
-   * the value indicating a version number until this member
-   * or type should be ignored.
+   * The value indicating a version number until this member or type should be be included.
+   * The number is exclusive; annotated elements will be included if {@code gsonVersion < value}.
    */
   double value();
 }
diff --git a/gson/src/main/java/com/google/gson/internal/Excluder.java b/gson/src/main/java/com/google/gson/internal/Excluder.java
--- a/gson/src/main/java/com/google/gson/internal/Excluder.java
+++ b/gson/src/main/java/com/google/gson/internal/Excluder.java
@@ -240,9 +240,7 @@ private boolean isValidVersion(Since since, Until until) {
   private boolean isValidSince(Since annotation) {
     if (annotation != null) {
       double annotationVersion = annotation.value();
-      if (annotationVersion > version) {
-        return false;
-      }
+      return version >= annotationVersion;
     }
     return true;
   }
@@ -250,9 +248,7 @@ private boolean isValidSince(Since annotation) {
   private boolean isValidUntil(Until annotation) {
     if (annotation != null) {
       double annotationVersion = annotation.value();
-      if (annotationVersion <= version) {
-        return false;
-      }
+      return version < annotationVersion;
     }
     return true;
   }


Test patch:
diff --git a/gson/src/test/java/com/google/gson/GsonBuilderTest.java b/gson/src/test/java/com/google/gson/GsonBuilderTest.java
--- a/gson/src/test/java/com/google/gson/GsonBuilderTest.java
+++ b/gson/src/test/java/com/google/gson/GsonBuilderTest.java
@@ -16,20 +16,25 @@
 
 package com.google.gson;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.fail;
+
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonWriter;
 import java.io.IOException;
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.Type;
-import junit.framework.TestCase;
+import org.junit.Test;
 
 /**
  * Unit tests for {@link GsonBuilder}.
  *
  * @author Inderjeet Singh
  */
-public class GsonBuilderTest extends TestCase {
+public class GsonBuilderTest {
   private static final TypeAdapter<Object> NULL_TYPE_ADAPTER = new TypeAdapter<Object>() {
     @Override public void write(JsonWriter out, Object value) {
       throw new AssertionError();
@@ -39,6 +44,7 @@ public class GsonBuilderTest extends TestCase {
     }
   };
 
+  @Test
   public void testCreatingMoreThanOnce() {
     GsonBuilder builder = new GsonBuilder();
     Gson gson = builder.create();
@@ -61,6 +67,7 @@ public void testCreatingMoreThanOnce() {
    * Gson instances should not be affected by subsequent modification of GsonBuilder
    * which created them.
    */
+  @Test
   public void testModificationAfterCreate() {
     GsonBuilder gsonBuilder = new GsonBuilder();
     Gson gson = gsonBuilder.create();
@@ -136,6 +143,7 @@ public CustomClass3() {
     }
   }
 
+  @Test
   public void testExcludeFieldsWithModifiers() {
     Gson gson = new GsonBuilder()
         .excludeFieldsWithModifiers(Modifier.VOLATILE, Modifier.PRIVATE)
@@ -151,6 +159,7 @@ static class HasModifiers {
     String d = "d";
   }
 
+  @Test
   public void testTransientFieldExclusion() {
     Gson gson = new GsonBuilder()
         .excludeFieldsWithModifiers()
@@ -162,6 +171,7 @@ static class HasTransients {
     transient String a = "a";
   }
 
+  @Test
   public void testRegisterTypeAdapterForCoreType() {
     Type[] types = {
         byte.class,
@@ -176,6 +186,7 @@ public void testRegisterTypeAdapterForCoreType() {
     }
   }
 
+  @Test
   public void testDisableJdkUnsafe() {
     Gson gson = new GsonBuilder()
         .disableJdkUnsafe()
@@ -198,4 +209,22 @@ private static class ClassWithoutNoArgsConstructor {
     public ClassWithoutNoArgsConstructor(String s) {
     }
   }
+
+  @Test
+  public void testSetVersionInvalid() {
+    GsonBuilder builder = new GsonBuilder();
+    try {
+      builder.setVersion(Double.NaN);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertEquals("Invalid version: NaN", e.getMessage());
+    }
+
+    try {
+      builder.setVersion(-0.1);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertEquals("Invalid version: -0.1", e.getMessage());
+    }
+  }
 }
diff --git a/gson/src/test/java/com/google/gson/VersionExclusionStrategyTest.java b/gson/src/test/java/com/google/gson/VersionExclusionStrategyTest.java
--- a/gson/src/test/java/com/google/gson/VersionExclusionStrategyTest.java
+++ b/gson/src/test/java/com/google/gson/VersionExclusionStrategyTest.java
@@ -16,40 +16,82 @@
 
 package com.google.gson;
 
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
 import com.google.gson.annotations.Since;
+import com.google.gson.annotations.Until;
 import com.google.gson.internal.Excluder;
-import junit.framework.TestCase;
+import org.junit.Test;
 
 /**
  * Unit tests for the {@link Excluder} class.
  *
  * @author Joel Leitch
  */
-public class VersionExclusionStrategyTest extends TestCase {
+public class VersionExclusionStrategyTest {
   private static final double VERSION = 5.0D;
 
-  public void testClassAndFieldAreAtSameVersion() throws Exception {
+  @Test
+  public void testSameVersion() throws Exception {
     Excluder excluder = Excluder.DEFAULT.withVersion(VERSION);
-    assertFalse(excluder.excludeClass(MockObject.class, true));
-    assertFalse(excluder.excludeField(MockObject.class.getField("someField"), true));
+    assertFalse(excluder.excludeClass(MockClassSince.class, true));
+    assertFalse(excluder.excludeField(MockClassSince.class.getField("someField"), true));
+
+    // Until version is exclusive
+    assertTrue(excluder.excludeClass(MockClassUntil.class, true));
+    assertTrue(excluder.excludeField(MockClassUntil.class.getField("someField"), true));
+
+    assertFalse(excluder.excludeClass(MockClassBoth.class, true));
+    assertFalse(excluder.excludeField(MockClassBoth.class.getField("someField"), true));
   }
 
-  public void testClassAndFieldAreBehindInVersion() throws Exception {
-    Excluder excluder = Excluder.DEFAULT.withVersion(VERSION + 1);
-    assertFalse(excluder.excludeClass(MockObject.class, true));
-    assertFalse(excluder.excludeField(MockObject.class.getField("someField"), true));
+  @Test
+  public void testNewerVersion() throws Exception {
+    Excluder excluder = Excluder.DEFAULT.withVersion(VERSION + 5);
+    assertFalse(excluder.excludeClass(MockClassSince.class, true));
+    assertFalse(excluder.excludeField(MockClassSince.class.getField("someField"), true));
+
+    assertTrue(excluder.excludeClass(MockClassUntil.class, true));
+    assertTrue(excluder.excludeField(MockClassUntil.class.getField("someField"), true));
+
+    assertTrue(excluder.excludeClass(MockClassBoth.class, true));
+    assertTrue(excluder.excludeField(MockClassBoth.class.getField("someField"), true));
+  }
+
+  @Test
+  public void testOlderVersion() throws Exception {
+    Excluder excluder = Excluder.DEFAULT.withVersion(VERSION - 5);
+    assertTrue(excluder.excludeClass(MockClassSince.class, true));
+    assertTrue(excluder.excludeField(MockClassSince.class.getField("someField"), true));
+
+    assertFalse(excluder.excludeClass(MockClassUntil.class, true));
+    assertFalse(excluder.excludeField(MockClassUntil.class.getField("someField"), true));
+
+    assertTrue(excluder.excludeClass(MockClassBoth.class, true));
+    assertTrue(excluder.excludeField(MockClassBoth.class.getField("someField"), true));
   }
 
-  public void testClassAndFieldAreAheadInVersion() throws Exception {
-    Excluder excluder = Excluder.DEFAULT.withVersion(VERSION - 1);
-    assertTrue(excluder.excludeClass(MockObject.class, true));
-    assertTrue(excluder.excludeField(MockObject.class.getField("someField"), true));
+  @Since(VERSION)
+  private static class MockClassSince {
+
+    @Since(VERSION)
+    public final int someField = 0;
+  }
+
+  @Until(VERSION)
+  private static class MockClassUntil {
+
+    @Until(VERSION)
+    public final int someField = 0;
   }
 
   @Since(VERSION)
-  private static class MockObject {
+  @Until(VERSION + 2)
+  private static class MockClassBoth {
 
     @Since(VERSION)
+    @Until(VERSION + 2)
     public final int someField = 0;
   }
 }
diff --git a/gson/src/test/java/com/google/gson/functional/VersioningTest.java b/gson/src/test/java/com/google/gson/functional/VersioningTest.java
--- a/gson/src/test/java/com/google/gson/functional/VersioningTest.java
+++ b/gson/src/test/java/com/google/gson/functional/VersioningTest.java
@@ -15,13 +15,17 @@
  */
 package com.google.gson.functional;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gson.annotations.Since;
 import com.google.gson.annotations.Until;
 import com.google.gson.common.TestTypes.BagOfPrimitives;
-
-import junit.framework.TestCase;
+import org.junit.Test;
 
 /**
  * Functional tests for versioning support in Gson.
@@ -29,47 +33,60 @@
  * @author Inderjeet Singh
  * @author Joel Leitch
  */
-public class VersioningTest extends TestCase {
+public class VersioningTest {
   private static final int A = 0;
   private static final int B = 1;
   private static final int C = 2;
   private static final int D = 3;
 
-  private GsonBuilder builder;
-
-  @Override
-  protected void setUp() throws Exception {
-    super.setUp();
-    builder = new GsonBuilder();
+  private static Gson gsonWithVersion(double version) {
+    return new GsonBuilder().setVersion(version).create();
   }
 
+  @Test
   public void testVersionedUntilSerialization() {
     Version1 target = new Version1();
-    Gson gson = builder.setVersion(1.29).create();
+    Gson gson = gsonWithVersion(1.29);
     String json = gson.toJson(target);
     assertTrue(json.contains("\"a\":" + A));
 
-    gson = builder.setVersion(1.3).create();
+    gson = gsonWithVersion(1.3);
+    json = gson.toJson(target);
+    assertFalse(json.contains("\"a\":" + A));
+
+    gson = gsonWithVersion(1.31);
     json = gson.toJson(target);
     assertFalse(json.contains("\"a\":" + A));
   }
 
+  @Test
   public void testVersionedUntilDeserialization() {
-    Gson gson = builder.setVersion(1.3).create();
     String json = "{\"a\":3,\"b\":4,\"c\":5}";
+
+    Gson gson = gsonWithVersion(1.29);
     Version1 version1 = gson.fromJson(json, Version1.class);
+    assertEquals(3, version1.a);
+
+    gson = gsonWithVersion(1.3);
+    version1 = gson.fromJson(json, Version1.class);
+    assertEquals(A, version1.a);
+
+    gson = gsonWithVersion(1.31);
+    version1 = gson.fromJson(json, Version1.class);
     assertEquals(A, version1.a);
   }
 
+  @Test
   public void testVersionedClassesSerialization() {
-    Gson gson = builder.setVersion(1.0).create();
+    Gson gson = gsonWithVersion(1.0);
     String json1 = gson.toJson(new Version1());
     String json2 = gson.toJson(new Version1_1());
     assertEquals(json1, json2);
   }
 
+  @Test
   public void testVersionedClassesDeserialization() {
-    Gson gson = builder.setVersion(1.0).create();
+    Gson gson = gsonWithVersion(1.0);
     String json = "{\"a\":3,\"b\":4,\"c\":5}";
     Version1 version1 = gson.fromJson(json, Version1.class);
     assertEquals(3, version1.a);
@@ -80,13 +97,15 @@ public void testVersionedClassesDeserialization() {
     assertEquals(C, version1_1.c);
   }
 
+  @Test
   public void testIgnoreLaterVersionClassSerialization() {
-    Gson gson = builder.setVersion(1.0).create();
+    Gson gson = gsonWithVersion(1.0);
     assertEquals("null", gson.toJson(new Version1_2()));
   }
 
+  @Test
   public void testIgnoreLaterVersionClassDeserialization() {
-    Gson gson = builder.setVersion(1.0).create();
+    Gson gson = gsonWithVersion(1.0);
     String json = "{\"a\":3,\"b\":4,\"c\":5,\"d\":6}";
     Version1_2 version1_2 = gson.fromJson(json, Version1_2.class);
     // Since the class is versioned to be after 1.0, we expect null
@@ -94,14 +113,16 @@ public void testIgnoreLaterVersionClassDeserialization() {
     assertNull(version1_2);
   }
 
+  @Test
   public void testVersionedGsonWithUnversionedClassesSerialization() {
-    Gson gson = builder.setVersion(1.0).create();
+    Gson gson = gsonWithVersion(1.0);
     BagOfPrimitives target = new BagOfPrimitives(10, 20, false, "stringValue");
     assertEquals(target.getExpectedJson(), gson.toJson(target));
   }
 
+  @Test
   public void testVersionedGsonWithUnversionedClassesDeserialization() {
-    Gson gson = builder.setVersion(1.0).create();
+    Gson gson = gsonWithVersion(1.0);
     String json = "{\"longValue\":10,\"intValue\":20,\"booleanValue\":false}";
 
     BagOfPrimitives expected = new BagOfPrimitives();
@@ -112,34 +133,45 @@ public void testVersionedGsonWithUnversionedClassesDeserialization() {
     assertEquals(expected, actual);
   }
 
+  @Test
   public void testVersionedGsonMixingSinceAndUntilSerialization() {
-    Gson gson = builder.setVersion(1.0).create();
+    Gson gson = gsonWithVersion(1.0);
     SinceUntilMixing target = new SinceUntilMixing();
     String json = gson.toJson(target);
     assertFalse(json.contains("\"b\":" + B));
 
-    gson = builder.setVersion(1.2).create();
+    gson = gsonWithVersion(1.2);
     json = gson.toJson(target);
     assertTrue(json.contains("\"b\":" + B));
 
-    gson = builder.setVersion(1.3).create();
+    gson = gsonWithVersion(1.3);
+    json = gson.toJson(target);
+    assertFalse(json.contains("\"b\":" + B));
+
+    gson = gsonWithVersion(1.4);
     json = gson.toJson(target);
     assertFalse(json.contains("\"b\":" + B));
   }
 
+  @Test
   public void testVersionedGsonMixingSinceAndUntilDeserialization() {
     String json = "{\"a\":5,\"b\":6}";
-    Gson gson = builder.setVersion(1.0).create();
+    Gson gson = gsonWithVersion(1.0);
     SinceUntilMixing result = gson.fromJson(json, SinceUntilMixing.class);
     assertEquals(5, result.a);
     assertEquals(B, result.b);
 
-    gson = builder.setVersion(1.2).create();
+    gson = gsonWithVersion(1.2);
     result = gson.fromJson(json, SinceUntilMixing.class);
     assertEquals(5, result.a);
     assertEquals(6, result.b);
 
-    gson = builder.setVersion(1.3).create();
+    gson = gsonWithVersion(1.3);
+    result = gson.fromJson(json, SinceUntilMixing.class);
+    assertEquals(5, result.a);
+    assertEquals(B, result.b);
+
+    gson = gsonWithVersion(1.4);
     result = gson.fromJson(json, SinceUntilMixing.class);
     assertEquals(5, result.a);
     assertEquals(B, result.b);