Skip to content

Commit e8d85c0

Browse files
authored
Inference for B2Json.constructor#params (#177)
Adds support for Java 8's `-parameters` option, so constructor parameters do not need to be reiterated in `B2Json.constructor#params`.
1 parent bcffcfa commit e8d85c0

File tree

5 files changed

+220
-21
lines changed

5 files changed

+220
-21
lines changed

README.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,7 @@ FAQ
313313
314314
B2Json requires that we make a constructor for each class that takes all
315315
of the parameters, so that we can assign values for final members and validate
316-
the values as needed. Since Java reflection doesn't let us access the names
317-
of the parameters to the constructors, we have to annotate constructors to
318-
provide the names. It's a price we're happy to pay for the features it
319-
provides.
316+
the values as needed.
320317
321318
322319
STRUCTURE

buildSrc/src/main/kotlin/b2sdk.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ java {
2323

2424
tasks.withType<JavaCompile>().configureEach {
2525
options.release.set(8)
26+
options.compilerArgs.add("-parameters")
2627
}
2728

2829
tasks.withType<Javadoc>().configureEach {

core/src/main/java/com/backblaze/b2/json/B2Json.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2018, Backblaze Inc. All Rights Reserved.
2+
* Copyright 2022, Backblaze Inc. All Rights Reserved.
33
* License https://www.backblaze.com/using_b2_code.html
44
*/
55

@@ -577,7 +577,8 @@ public <T> T fromUrlParameterMap(Map<String, String> parameterMap, Class<T> claz
577577
* should use. This constructor must take ALL of the serializable
578578
* fields as parameters.
579579
*
580-
* You must provide an "params" parameter that lists the order of
580+
* You must either compile classes with the '-parameters' javac option
581+
* or else provide a "params" parameter that lists the order of
581582
* the parameters to the constructor.
582583
*
583584
* If present, the "discards" parameter is a comma-separated list of
@@ -595,7 +596,10 @@ public <T> T fromUrlParameterMap(Map<String, String> parameterMap, Class<T> claz
595596
@Retention(RetentionPolicy.RUNTIME)
596597
@Target(ElementType.CONSTRUCTOR)
597598
public @interface constructor {
598-
String params();
599+
/**
600+
* This is optional for classes compiled with the '-parameters' javac argument
601+
*/
602+
String params() default "";
599603
String discards() default "";
600604
String versionParam() default "";
601605
}

core/src/main/java/com/backblaze/b2/json/B2JsonObjectHandler.java

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2018, Backblaze Inc. All Rights Reserved.
2+
* Copyright 2022, Backblaze Inc. All Rights Reserved.
33
* License https://www.backblaze.com/using_b2_code.html
44
*/
55

@@ -11,10 +11,7 @@
1111

1212
import java.io.IOException;
1313
import java.io.StringReader;
14-
import java.lang.reflect.Constructor;
15-
import java.lang.reflect.Field;
16-
import java.lang.reflect.InvocationTargetException;
17-
import java.lang.reflect.Type;
14+
import java.lang.reflect.*;
1815
import java.util.Arrays;
1916
import java.util.BitSet;
2017
import java.util.HashMap;
@@ -162,21 +159,38 @@ protected void initializeImplementation(B2JsonHandlerMap handlerMap) throws B2Js
162159

163160
// Figure out the argument positions for the constructor.
164161
{
162+
// Parse @B2Json.constructor#params into an array
165163
String paramsWithCommas = annotation.params().replace(" ", "");
166-
String [] paramNames = paramsWithCommas.split(",");
167-
if (paramNames.length == 1 && paramNames[0].length() == 0) {
168-
paramNames = new String [0];
164+
String[] annotationParamNames = paramsWithCommas.split(",");
165+
if (annotationParamNames.length == 1 && annotationParamNames[0].length() == 0) {
166+
annotationParamNames = null;
169167
}
170168

171-
final int constructorParamCount = fields.length + numberOfVersionParams;
172-
if (paramNames.length != constructorParamCount) {
173-
throw new IllegalArgumentException(clazz.getName() + " constructor does not have the right number of parameters");
169+
// Verify that, if present, the number of params specified in the annotation is correct
170+
final int expectedParamCount = fields.length + numberOfVersionParams;
171+
if (annotationParamNames != null && annotationParamNames.length != expectedParamCount) {
172+
throw new B2JsonException(clazz.getName() + " constructor's @B2Json.constructor annotation does not have the right number of params.");
173+
}
174+
175+
// Verify that the number of actual constructor params is correct
176+
Parameter[] constructorParams = this.constructor.getParameters();
177+
if (constructorParams.length != expectedParamCount) {
178+
throw new B2JsonException(clazz.getName() + " constructor does not have the right number of parameters");
174179
}
175180

176181
Integer versionParamIndex = null;
177182
Set<String> paramNamesSeen = new HashSet<>();
178-
for (int i = 0; i < paramNames.length; i++) {
179-
String paramName = paramNames[i];
183+
for (int i = 0; i < constructorParams.length; i++) {
184+
// Use annotated param names, if provided. Otherwise, attempt to use Java 8's real parameter name reflection
185+
String paramName;
186+
if (annotationParamNames != null) {
187+
paramName = annotationParamNames[i];
188+
} else if (constructorParams[i].isNamePresent()) {
189+
paramName = constructorParams[i].getName();
190+
} else {
191+
throw new B2JsonException(clazz.getName() + " constructor is missing 'params' for its @B2Json.constructor annotation. Either specify this or add -parameters to javac args.");
192+
}
193+
180194
if (paramNamesSeen.contains(paramName)) {
181195
throw new B2JsonException(clazz.getName() + " constructor parameter '" + paramName + "' listed twice");
182196
}
@@ -196,7 +210,7 @@ protected void initializeImplementation(B2JsonHandlerMap handlerMap) throws B2Js
196210
}
197211
}
198212
this.versionParamIndexOrNull = versionParamIndex;
199-
this.constructorParamCount = constructorParamCount;
213+
this.constructorParamCount = constructorParams.length;
200214
}
201215

202216
// figure out which names to discard, if any
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
* Copyright 2022, Backblaze Inc. All Rights Reserved.
3+
* License https://www.backblaze.com/using_b2_code.html
4+
*/
5+
6+
package com.backblaze.b2.json;
7+
8+
import com.backblaze.b2.util.B2BaseTest;
9+
import org.junit.Rule;
10+
import org.junit.Test;
11+
import org.junit.rules.ExpectedException;
12+
13+
import java.util.*;
14+
15+
import static org.junit.Assert.*;
16+
17+
/**
18+
* Unit tests for B2Json relying on javac's '-parameters' option instead of {@link B2Json.constructor#params}.
19+
*/
20+
@SuppressWarnings({
21+
"unused", // A lot of the test classes have things that aren't used, but we don't care.
22+
"WeakerAccess" // A lot of the test classes could have weaker access, but we don't care.
23+
})
24+
public class B2JsonInferredParametersTest extends B2BaseTest {
25+
26+
@Rule
27+
public ExpectedException thrown = ExpectedException.none();
28+
29+
@Test
30+
public void testConstructorWithMissingFields() throws B2JsonException {
31+
String json = "{\"a\": 41}";
32+
thrown.expect(B2JsonException.class);
33+
thrown.expectMessage("constructor does not have the right number of parameters");
34+
b2Json.fromJson(json, ConstructorWithMissingFields.class);
35+
}
36+
37+
@Test
38+
public void testConstructorWithExtraFields() throws B2JsonException {
39+
String json = "{\"a\": 41}";
40+
thrown.expect(B2JsonException.class);
41+
thrown.expectMessage("constructor does not have the right number of parameters");
42+
b2Json.fromJson(json, ConstructorWithExtraFields.class);
43+
}
44+
45+
@Test
46+
public void testConstructorWithVersion() throws B2JsonException {
47+
final String json = "{}";
48+
final B2JsonOptions options = B2JsonOptions.builder().setVersion(1).build();
49+
final VersionedContainer obj = b2Json.fromJson(json, VersionedContainer.class, options);
50+
assertEquals(0, obj.x);
51+
assertEquals(1, obj.version);
52+
}
53+
54+
@Test
55+
public void testDeserializeEmpty() throws B2JsonException {
56+
final Empty actual = B2Json.fromJsonOrThrowRuntime("{}", Empty.class);
57+
}
58+
59+
/**
60+
* Deserialize a class with fields declared in different order than their corresponding
61+
* parameters in the constructor.
62+
*/
63+
@Test
64+
public void testDeserializeWithMismatchingParamOrder() throws B2JsonException {
65+
final MismatchingOrderContainer actual = B2Json.fromJsonOrThrowRuntime(
66+
"{\n" +
67+
" \"a\": 41,\n" +
68+
" \"b\": \"hello\",\n" +
69+
" \"c\": 101\n" +
70+
"}",
71+
MismatchingOrderContainer.class);
72+
}
73+
74+
75+
private static class Empty {
76+
@B2Json.constructor Empty() {}
77+
}
78+
79+
private static class ConstructorWithMissingFields {
80+
@B2Json.required
81+
public int a;
82+
83+
@B2Json.constructor ConstructorWithMissingFields() {}
84+
}
85+
86+
private static class ConstructorWithExtraFields {
87+
@B2Json.required
88+
public int a;
89+
90+
@B2Json.constructor ConstructorWithExtraFields(int a, String extraField) {}
91+
}
92+
93+
private static class VersionedContainer {
94+
@B2Json.versionRange(firstVersion = 4, lastVersion = 6)
95+
@B2Json.required
96+
public final int x;
97+
98+
@B2Json.ignored
99+
public final int version;
100+
101+
@B2Json.constructor(versionParam = "v")
102+
public VersionedContainer(int x, int v) {
103+
this.x = x;
104+
this.version = v;
105+
}
106+
}
107+
108+
private static final class MismatchingOrderContainer {
109+
110+
@B2Json.required
111+
public final int a;
112+
113+
@B2Json.required
114+
public final String b;
115+
116+
@B2Json.required
117+
public int c;
118+
119+
@B2Json.optional
120+
public final Empty d;
121+
122+
@B2Json.constructor
123+
public MismatchingOrderContainer(int c, String b, int a, Empty d) {
124+
this.c = c;
125+
this.b = b;
126+
this.a = a;
127+
this.d = d;
128+
}
129+
130+
@Override
131+
public boolean equals(Object o) {
132+
if (this == o) return true;
133+
if (o == null || getClass() != o.getClass()) return false;
134+
135+
MismatchingOrderContainer that = (MismatchingOrderContainer) o;
136+
137+
if (a != that.a) return false;
138+
if (c != that.c) return false;
139+
if (!Objects.equals(b, that.b)) return false;
140+
return Objects.equals(d, that.d);
141+
}
142+
143+
@Override
144+
public int hashCode() {
145+
int result = a;
146+
result = 31 * result + (b != null ? b.hashCode() : 0);
147+
result = 31 * result + c;
148+
result = 31 * result + (d != null ? d.hashCode() : 0);
149+
return result;
150+
}
151+
}
152+
153+
private static final class Container {
154+
155+
@B2Json.required
156+
public final int a;
157+
158+
@B2Json.optional
159+
public final String b;
160+
161+
@B2Json.ignored
162+
public int c;
163+
164+
@B2Json.constructor
165+
public Container(int a, String b) {
166+
this.a = a;
167+
this.b = b;
168+
this.c = 5;
169+
}
170+
171+
@Override
172+
public boolean equals(Object o) {
173+
if (!(o instanceof Container)) {
174+
return false;
175+
}
176+
Container other = (Container) o;
177+
return a == other.a && (b == null ? other.b == null : b.equals(other.b));
178+
}
179+
}
180+
181+
private static final B2Json b2Json = B2Json.get();
182+
183+
}

0 commit comments

Comments
 (0)