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

Adjust Record adapter and extend test coverage #2224

Merged
merged 7 commits into from
Oct 22, 2022

Conversation

Marcono1234
Copy link
Collaborator

@Marcono1234 Marcono1234 commented Oct 14, 2022

Changes:

  • Make record constructor and accessor accessible (unless reflection access filter prevents it)
  • Disallow null for primitive component, see Small adjustments to the new record code. #2219 (comment)
  • Skip static fields when deserializing records
    Would not work without special casing and I am not sure if serializing or deserializing static fields was ever officially documented as supported (just happens to work by using GsonBuilder.excludeFieldsWithModifiers). For serialization there might be legit use cases (e.g. to encode a version number), but deserialization it rather questionable.
  • Disallow @SerializedName placed only on accessor method
    Implemented this because we have the accessor method available for this check anyway, and because for records the accessor method is actually called on serialization so users might expect @SerializedName to work on them.
  • Adjust ReflectionHelper.makeAccessible to create description of object only when exception is thrown instead of eagerly
  • Prefix names of test classes ReflectiveTypeAdapterFactoryTest and ReflectionHelperTest with Java17
    This makes sure that we notice when the checked JDK class UnixDomainPrincipal is missing for some reason even though the test runs on Java >= 17. Otherwise these tests might just be silently skipped without us noticing.
    Arguably these tests could also be rewritten to use custom record classes now that the tests are only compiled on Java 17 or newer, but on the other hand it might also be valuable that they test against a record class which is not part of Gson's tests.
  • ... for further changes see messages of subsequent commits

}

@Test
public void testCustomAdapterForRecords() {
Gson gson = new Gson();
TypeAdapter<?> recordAdapter = gson.getAdapter(unixDomainPrincipalClass);
TypeAdapter<?> defaultReflectionAdapter = gson.getAdapter(UserPrincipal.class);
TypeAdapter<?> defaultReflectionAdapter = gson.getAdapter(DummyClass.class);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have changed this because UserPrincipal is an interface without any fields, so assuming that the normal reflection based adapter is used for it might be a bit brittle.

Copy link
Member

@eamonnmcmanus eamonnmcmanus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this!

This is my first pass. I think I will need to spend more time looking at this, though.

accessor = ReflectionHelper.getAccessor(raw, field);
if (isRecord) {
// If there is a static field on a record, there will not be an accessor. Instead we will use the default
// field logic for dealing with statics. For deserialization the field is ignored.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify: by default, static fields in records will be neither serialized nor deserialized, right? Only if that weird excludeFieldsWithModifiers method is called? Because I definitely don't think static fields should be serialized.

Copy link
Collaborator Author

@Marcono1234 Marcono1234 Oct 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that is correct, see default excluded modifiers here:

private int modifiers = Modifier.TRANSIENT | Modifier.STATIC;

I have mainly added this to handle it in a reasonable way instead of failing with some obscure exception.

For static fields maybe there are legit (opt-in) use cases, e.g. to automatically encode a version number:

record Response(
   ...
) {
    public static final int VERSION = 1;
}

Though I am not sure if someone is actually using this, and it is certainly not obvious (and probably not even officially supported) to use excludeFieldsWithModifiers for this.

If you want I can adjust it to also always exclude static fields on serialization, and we can then wait for someone to request this feature.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think by default static fields should not be serialized. I suppose they should be if someone calls excludeFieldsWithModifiers(0), though even then I don't really understand why people would want that. I'm not really convinced by the VERSION use case. We would serialize that field, and then what would we do on deserialization? Do we check that the version matches? Do we try to overwrite the VERSION field? If we don't, isn't that inconsistent? I think if you really want a version then it should either be an instance field or you should use a custom TypeAdapter.

(I looked at the history, and apparently excludeFieldsWithModifiers was already present in the first version on GitHub, dated 2008. So who knows why it was added. For what it's worth, Google's code has only a handful of calls to this method, and exactly one that is including STATIC, perhaps accidentally.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really convinced by the VERSION use case. We would serialize that field, and then what would we do on deserialization?

It would probably be most useful for services which only serialize data, e.g. a REST API which generates the response. At least that is the most reasonable situation I can come up with at the moment.

It appears the excludeFieldsWithModifiers trick is known, see this StackOverflow answer.

public void testStaticFieldSerialization() {
Gson gson = new GsonBuilder()
// Include static fields
.excludeFieldsWithModifiers(0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also test that the field is not serialized without this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like there was already a test for this: com.google.gson.functional.UncategorizedTest.testStaticFieldsAreNotSerialized()
And additionally multiple tests implicitly relied on static fields being ignored by default. However, I have now extended these new test methods to also test the default Gson behavior.

Also, I have noticed that Field.set per documentation does not support static final fields. So to avoid Gson claiming "Unexpected IllegalAccessException occurred...", I have added custom handling for that case to provide a better exception message.

Copy link
Member

@eamonnmcmanus eamonnmcmanus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(It was unclear to me whether there is in fact test coverage for static fields not being serialized by default.)

@Marcono1234 Marcono1234 marked this pull request as draft October 18, 2022 23:14
Previously it would report "Unexpected IllegalAccessException occurred..."
due to the uncaught IllegalAccessException.
Such an exception is not 'unexpected' (which was claimed by the previous
exception handling) because user code could throw it.
@Marcono1234 Marcono1234 marked this pull request as ready for review October 18, 2022 23:24
@Marcono1234
Copy link
Collaborator Author

Sorry for these additional commits. I did not consider these cases initially and only noticed now that they are not covered by any tests. I hope the additional commits I pushed are fine.

/** Tests behavior when the canonical constructor throws an exception */
@Test
public void testThrowingConstructor() {
record LocalRecord(String s) {
Copy link
Collaborator Author

@Marcono1234 Marcono1234 Oct 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears the Eclipse compiler produces malformed byte code for this record class (have not investigated that further yet). Just as forewarning in case you are experiencing a java.lang.VerifyError when running this test in Eclipse.

Edit: Have created eclipse-jdt/eclipse.jdt.core#487 for that.

Copy link
Member

@eamonnmcmanus eamonnmcmanus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Let me know if you think it is ready to be merged.

@Marcono1234
Copy link
Collaborator Author

It should be ready to merge.

In case you plan to create a release soon (per Semantic Versioning that would be 2.10.0 I assume), it would be good if you could take a look at #2212. If those changes are too experimental / hacky in your opinion, then at least during the release process the @since Javadoc tags for the new methods would have to be added manually.

@eamonnmcmanus eamonnmcmanus merged commit 66d9621 into google:master Oct 22, 2022
@Marcono1234 Marcono1234 deleted the marcono1234/record-follow-up branch October 22, 2022 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants