Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for omitting defaults #269

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ useful for setting default values.
}
```

### Omitting default values

When using a builder, optional properties that are at their default values can optionally be emitted
from the serialized JSON by annotating the class with `@OmitDefaults`.

A property is considered optional if a value is set for it on the builder returned by the static
builder method and the builder defines a getter method for it:

```java
@AutoValue public abstract class Foo {
abstract int bar();
abstract String quux();

public static Builder builderWithDefaults() {
return new AutoValue_Foo.Builder()
.quuz("QUUX");
}

@AutoValue.Builder
public static abstract class Builder {
public abstract Builder bar(int bar);
public abstract Builder quux(String quux);
// getter for quux
public abstract String quux();
public abstract Foo build();
}
}
```

## Field name policy

If you want the generated adapter classes to use the input `Gson` instance's field name policy, you can
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
Expand All @@ -54,7 +55,9 @@
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.SupportedOptions;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.TypeParameterElement;
import javax.lang.model.type.DeclaredType;
Expand Down Expand Up @@ -173,6 +176,7 @@ Optional<AnnotationMirror> nullableMethodAnnotation() {
}

private boolean useFieldNamePolicy = false;
private boolean omitDefaults = false;

@Override
public IncrementalExtensionType incrementalType(ProcessingEnvironment processingEnvironment) {
Expand All @@ -184,6 +188,8 @@ public boolean applicable(Context context) {
useFieldNamePolicy = context.processingEnvironment()
.getOptions()
.containsKey(USE_FIELD_NAME_POLICY);
//noinspection UnstableApiUsage
omitDefaults = MoreElements.isAnnotationPresent(context.autoValueClass(), OmitDefaults.class);
return isApplicable(context.autoValueClass(), context.processingEnvironment().getMessager());
}

Expand Down Expand Up @@ -464,6 +470,13 @@ private TypeSpec createTypeAdapter(
List<TypeVariableName> typeParams,
@Nullable BuilderContext builderContext,
ProcessingEnvironment processingEnvironment) {
if (omitDefaults && builderContext == null) {
processingEnvironment.getMessager()
.printMessage(
Diagnostic.Kind.ERROR,
"@OmitDefaults can only be applied to classes with a builder",
autoValueType);
}
ClassName typeAdapterClass = ClassName.get(TypeAdapter.class);
final TypeName autoValueTypeName = !typeParams.isEmpty()
? ParameterizedTypeName.get(autoValueClassName, typeParams.toArray(new TypeName[typeParams.size()]))
Expand All @@ -472,7 +485,7 @@ private TypeSpec createTypeAdapter(

ParameterSpec gsonParam = ParameterSpec.builder(Gson.class, "gson").build();
MethodSpec.Builder constructor = MethodSpec.constructorBuilder()
.addParameter(gsonParam);
.addParameter(gsonParam);

if (!typeParams.isEmpty()) {

Expand Down Expand Up @@ -512,7 +525,7 @@ private TypeSpec createTypeAdapter(
classBuilder.addField(FieldSpec.builder(Gson.class, "gson", PRIVATE, FINAL).build())
.addMethod(constructor.build())
.addMethod(createWriteMethod(autoValueTypeName, properties, adapters,
jsonAdapter, typeParams))
jsonAdapter, typeParams, builderContext))
.addMethod(createReadMethod(className, autoValueClassName, autoValueTypeName, properties,
adapters, jsonAdapter, typeParams, builderContext, processingEnvironment))
.addMethod(MethodSpec.methodBuilder("toString")
Expand All @@ -531,6 +544,15 @@ private TypeSpec createTypeAdapter(
classBuilder.addField(FieldSpec.builder(Type[].class, "typeArgs", PRIVATE, FINAL).build());
}

if (omitDefaults) {
ExecutableElement builderMethod =
findBuilderMethod(builderContext).orElseThrow(IllegalStateException::new);
classBuilder.addField(
FieldSpec.builder(ClassName.get(builderContext.builderType().asType()), "defaults", PRIVATE, FINAL)
.initializer("$T.$N()", autoValueClassName, builderMethod.getSimpleName())
.build());
}

return classBuilder.build();
}

Expand Down Expand Up @@ -685,11 +707,13 @@ private static void addFieldSetting(CodeBlock.Builder block,
block.addStatement("$N = $N.read($N)", fields.get(prop), adapter, jsonReader);
}

private MethodSpec createWriteMethod(TypeName autoValueClassName,
private MethodSpec createWriteMethod(
TypeName autoValueClassName,
List<Property> properties,
ImmutableMap<TypeName, FieldSpec> adapters,
ClassName jsonAdapter,
List<TypeVariableName> typeParams) {
List<TypeVariableName> typeParams,
@Nullable BuilderContext builderContext) {
ParameterSpec jsonWriter = ParameterSpec.builder(JsonWriter.class, "jsonWriter").build();
ParameterSpec annotatedParam = ParameterSpec.builder(autoValueClassName, "object").build();
MethodSpec.Builder writeMethod = MethodSpec.methodBuilder("write")
Expand All @@ -707,11 +731,46 @@ private MethodSpec createWriteMethod(TypeName autoValueClassName,
writeMethod.addStatement("return");
writeMethod.endControlFlow();

writeMethod.addStatement("boolean isOptionalAndAtDefault");

writeMethod.addStatement("$N.beginObject()", jsonWriter);
for (Property prop : properties) {
if (prop.isTransient()) {
continue;
}
Optional<ExecutableElement> builderGetter = Optional.empty();
if (omitDefaults) {
// A property is optional if it has a matching getter in the builder. There is no
// convenience method for this in BuilderContext, so we look for it manually.
// https://github.com/google/auto/blob/main/value/userguide/builders-howto.md#-normalize-modify-a-property-value-at-build-time
builderGetter = builderContext.builderType()
.getEnclosedElements()
.stream()
.filter(e -> e.getSimpleName().contentEquals(prop.methodName))
.filter(e -> e.getKind() == ElementKind.METHOD)
.map(e -> (ExecutableElement) e)
.filter(e -> e.getParameters().isEmpty())
.filter(e -> e.getModifiers().contains(ABSTRACT))
.filter(e -> e.getReturnType().equals(prop.element.getReturnType()))
.findFirst();
}

builderGetter.ifPresent(getter -> {
writeMethod.beginControlFlow("try");
writeMethod.addStatement("isOptionalAndAtDefault = $T.equals(defaults.$N(), $N.$N())",
Objects.class,
getter.getSimpleName(),
annotatedParam,
prop.methodName);
// A builder getter throws IllegalStateException if the builder doesn't have the property
// set and the property is non-nullable, i.e., it isn't optional.
writeMethod.nextControlFlow("catch ($T e)", IllegalStateException.class);
writeMethod.addStatement("isOptionalAndAtDefault = false");
writeMethod.endControlFlow();

writeMethod.beginControlFlow("if (!isOptionalAndAtDefault)");
});

if (prop.hasSerializedNameAnnotation()) {
writeMethod.addStatement("$N.name($S)", jsonWriter, prop.serializedName());
} else if (useFieldNamePolicy) {
Expand Down Expand Up @@ -741,6 +800,8 @@ private MethodSpec createWriteMethod(TypeName autoValueClassName,
block.add("}\n");
writeMethod.addCode(block.build());
}

builderGetter.ifPresent(executableElement -> writeMethod.endControlFlow());
}
writeMethod.addStatement("$N.endObject()", jsonWriter);

Expand Down Expand Up @@ -831,23 +892,8 @@ private MethodSpec createReadMethod(ClassName className,
readMethod.addStatement("$T $N = new $T.$L()", builderField.get().type, builderField.get(),
className, builderContext.builderType().getSimpleName());
} else {
ExecutableElement builderMethod;
if (builderMethods.size() == 1) {
// If there is only 1, use it.
builderMethod = builderMethods.stream().findFirst().get();
} else {
// Otherwise, find the only builder method that is annotated.
Set<ExecutableElement> annotatedMethods = builderMethods.stream()
.filter(e -> MoreElements.isAnnotationPresent(e, AutoValueGsonBuilder.class))
.collect(Collectors.toSet());

if (annotatedMethods.size() == 1) {
builderMethod = annotatedMethods.stream().findFirst().get();
} else {
throw new IllegalStateException();
}
}

ExecutableElement builderMethod =
findBuilderMethod(builderContext).orElseThrow(IllegalStateException::new);
readMethod.addStatement("$T $N = $T.$N()", builderField.get().type, builderField.get(),
autoValueClassName, builderMethod.getSimpleName());
}
Expand Down Expand Up @@ -961,6 +1007,25 @@ private MethodSpec createReadMethod(ClassName className,
return readMethod.build();
}

private static Optional<ExecutableElement> findBuilderMethod(BuilderContext builderContext) {
Set<ExecutableElement> builderMethods = builderContext.builderMethods();
if (builderMethods.size() == 1) {
// If there is only 1, use it.
return builderMethods.stream().findFirst();
} else {
// Otherwise, find the only builder method that is annotated.
Set<ExecutableElement> annotatedMethods = builderMethods.stream()
.filter(e -> MoreElements.isAnnotationPresent(e, AutoValueGsonBuilder.class))
.collect(Collectors.toSet());

if (annotatedMethods.size() == 1) {
return annotatedMethods.stream().findFirst();
} else {
return Optional.empty();
}
}
}

/**
* Returns a default value for initializing well-known types, or else {@code null}.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.ryanharter.auto.value.gson;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.CLASS;

/**
* If present, optional properties at their default values will be omitted from the serialized JSON.
*
* <p>Requires the class to provide a static builder method returning an instance of the
* corresponding {@link com.google.auto.value.AutoValue.Builder} with the optional properties set
* to their default values.
*
* <p>A property is considered optional if
* <ul>
* <li>a value is set for it on the builder returned by the static builder method and
* <li>the builder defines a getter method for it ({@see
* <a href="https://github.com/google/auto/blob/main/value/userguide/builders-howto.md#-normalize-modify-a-property-value-at-build-time">Auto Value documentation</a>}).
* Builder getters returning {@link java.util.Optional} wrappers are not (yet) supported.
* </ul>
*
*/
@Retention(CLASS)
@Target(TYPE)
public @interface OmitDefaults {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import com.google.auto.value.AutoValue;
import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import com.ryanharter.auto.value.gson.OmitDefaults;
import java.util.Date;

@AutoValue
@OmitDefaults
public abstract class Person {
public abstract String name();

Expand All @@ -32,10 +34,16 @@ public static TypeAdapter<Person> typeAdapter(Gson gson) {
public static abstract class Builder {
public abstract Builder name(String name);

public abstract String name();

public abstract Builder gender(int gender);

public abstract int gender();

public abstract Builder age(int age);

public abstract int age();

public abstract Builder birthdate(Date birthdate);

public abstract Builder address(Address address);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import com.google.gson.JsonObject;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
Expand Down Expand Up @@ -76,4 +77,29 @@ public void testGsonWithDefaults() {
Assert.assertEquals(23, fromJson.age());
Assert.assertEquals(0, fromJson.gender());
}

@Test
public void testGsonWithDefaultsWrite() {
Gson gson = new GsonBuilder()
.registerTypeAdapterFactory(SampleAdapterFactory.create())
.create();

// gender has the default value and should be omitted from the output.
// name is also optional but not the default, it should be included.
// age has a builder getter defined, but isn't optional, it should be included.
Person toJson = Person.builder()
.name("Auto Value")
.gender(0)
.age(42)
.birthdate(new Date())
.address(Address.create("street", "city"))
.build();
String json = gson.toJson(toJson, Person.class);
JsonObject rawObject = gson.fromJson(json, JsonObject.class);
Assert.assertFalse(rawObject.has("gender"));
Assert.assertTrue(rawObject.has("name"));
Assert.assertEquals(rawObject.get("name").getAsString(), "Auto Value");
Assert.assertTrue(rawObject.has("age"));
Assert.assertEquals(rawObject.get("age").getAsInt(), 42);
}
}