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

NumberFormat support #4273

Open
wants to merge 4 commits into
base: 2.17
Choose a base branch
from
Open

NumberFormat support #4273

wants to merge 4 commits into from

Conversation

hurelhuyag
Copy link

@hurelhuyag hurelhuyag commented Dec 20, 2023

Fixes #1114.

@cowtowncoder
Copy link
Member

Ah. Thank you for contributing this!

One quick note: we'd need unit tests to show how this works; class Javadocs would be useful too, to indicate expectation of using format notation of DecimalFormat

@@ -55,6 +64,9 @@ public JsonSerializer<?> createContextual(SerializerProvider prov,
if (format != null) {
switch (format.getShape()) {
case STRING:
if (format.hasPattern()) {
return new NumberSerializer(handledType(), new DecimalFormat(format.getPattern()));
Copy link
Member

Choose a reason for hiding this comment

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

Probably needs a try-catch block for invalid format (and probably unit test to show handling), re-throw exception (using one of methods from SerializerProvider).

@cowtowncoder
Copy link
Member

cowtowncoder commented Dec 22, 2023

I realized that there is a good reason why I'd like to see tests: I am pretty sure this will not work for most numeric fields :)
Specifically, for int/Integer, long/Long.
(I can elaborate why, but it'd be good to add tests first to see if I am right or not).


public void testInvalidPattern() throws Exception {
ObjectMapper mapper = newJsonMapper();
Assert.assertThrows(JsonMappingException.class, () -> {
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 check the exception message too (so it's the expected exception)

@cowtowncoder
Copy link
Member

@hurelhuyag I think we can made this work. I hope you don't mind my pushing couple of changes to streamline things.

The next thing, I think, is adding bit more testing -- tests you added look good! -- for other number types; int, long, double (assuming these can be formatted with DecimalFormat? At least double/Double should, right?).

And then I think we need to resolve use of shape wrt values other than STRING / NUMBER / NUMBER_INT / NUMBER_FLOAT. Specially, what to do with ANY / missing.

Copy link
Member

@JooHyukKim JooHyukKim left a comment

Choose a reason for hiding this comment

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

🔥

}
} catch (IllegalArgumentException e) {
return prov.reportBadDefinition(handledType(),
String.format("Invalid `DecimalFormat`: \"%s\"", format.getPattern()));
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
String.format("Invalid `DecimalFormat`: \"%s\"", format.getPattern()));
String.format("Invalid 'DecimalFormat': \"%s\"", format.getPattern()));

Can we use single quote instead of backtick? I looked around the codebase and in most cases (not all) single quote is used.

Comment on lines +24 to +52
public void testTypeDefaults() throws Exception
{
ObjectMapper mapper = newJsonMapper();
mapper.configOverride(BigDecimal.class)
.setFormat(new JsonFormat.Value("00,000.00", JsonFormat.Shape.STRING, (Locale) null, (TimeZone) null, null, null));
String json = mapper.writeValueAsString(new NumberWrapper(new BigDecimal("1234")));
assertEquals(a2q("{'value':'01,234.00'}"), json);

// and then read back is not supported yet.
/*NumberWrapper w = mapper.readValue(a2q("{'value':'01,234.00'}"), NumberWrapper.class);
assertNotNull(w);
assertEquals(new BigDecimal("1234"), w.value);*/
}

protected static class InvalidPatternWrapper {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "#,##0.#.#")
public BigDecimal value;

public InvalidPatternWrapper(BigDecimal value) {
this.value = value;
}
}

public void testInvalidPattern() throws Exception {
ObjectMapper mapper = newJsonMapper();
Assert.assertThrows(JsonMappingException.class, () -> {
mapper.writeValueAsString(new InvalidPatternWrapper(BigDecimal.ZERO));
});
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
public void testTypeDefaults() throws Exception
{
ObjectMapper mapper = newJsonMapper();
mapper.configOverride(BigDecimal.class)
.setFormat(new JsonFormat.Value("00,000.00", JsonFormat.Shape.STRING, (Locale) null, (TimeZone) null, null, null));
String json = mapper.writeValueAsString(new NumberWrapper(new BigDecimal("1234")));
assertEquals(a2q("{'value':'01,234.00'}"), json);
// and then read back is not supported yet.
/*NumberWrapper w = mapper.readValue(a2q("{'value':'01,234.00'}"), NumberWrapper.class);
assertNotNull(w);
assertEquals(new BigDecimal("1234"), w.value);*/
}
protected static class InvalidPatternWrapper {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "#,##0.#.#")
public BigDecimal value;
public InvalidPatternWrapper(BigDecimal value) {
this.value = value;
}
}
public void testInvalidPattern() throws Exception {
ObjectMapper mapper = newJsonMapper();
Assert.assertThrows(JsonMappingException.class, () -> {
mapper.writeValueAsString(new InvalidPatternWrapper(BigDecimal.ZERO));
});
}
protected static class InvalidPatternWrapper {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "#,##0.#.#")
public BigDecimal value;
public InvalidPatternWrapper(BigDecimal value) {
this.value = value;
}
}
@Test
public void testTypeDefaults() throws Exception
{
ObjectMapper mapper = newJsonMapper();
mapper.configOverride(BigDecimal.class)
.setFormat(new JsonFormat.Value("00,000.00", JsonFormat.Shape.STRING, (Locale) null, (TimeZone) null, null, null));
String json = mapper.writeValueAsString(new NumberWrapper(new BigDecimal("1234")));
assertEquals(a2q("{'value':'01,234.00'}"), json);
// and then read back is not supported yet.
/*NumberWrapper w = mapper.readValue(a2q("{'value':'01,234.00'}"), NumberWrapper.class);
assertNotNull(w);
assertEquals(new BigDecimal("1234"), w.value);*/
}
@Test
public void testInvalidPattern() throws Exception {
ObjectMapper mapper = newJsonMapper();
try {
mapper.writeValueAsString(new InvalidPatternWrapper(BigDecimal.ZERO));
fail();
} catch (JsonMappingException e) {
verifyException(e, "expected message part 1/2"); //
verifyException(e, "expected message part 2/2");
}
}

Couple of suggestion on test if that's okay with you 🙂

  1. Let's use JUnit 5, (meaning remove extends BaseMapTest
  2. Class declaration comes first then test methods (Check suggestion)
  3. As suggested above, check message by using try-catch and verifyException (from DatabindTestUtil) combo

Copy link
Member

Choose a reason for hiding this comment

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

Above comment seems like Jackson Coding Style convention idea 🤔... WDYT, @cowtowncoder ?

@@ -55,6 +73,21 @@ public JsonSerializer<?> createContextual(SerializerProvider prov,
if (format != null) {
switch (format.getShape()) {
case STRING:
if (format.hasPattern()) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if (format.hasPattern()) {
// [databind#1114] since 2.17 : Support @JsonFormat for String, numbers, using String.format()
if (format.hasPattern()) {

@@ -69,6 +102,10 @@ public JsonSerializer<?> createContextual(SerializerProvider prov,
@Override
public void serialize(Number value, JsonGenerator g, SerializerProvider provider) throws IOException
{
if (_format != null) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if (_format != null) {
// [databind#1114] since 2.17 : Support @JsonFormat for String, numbers, using String.format()
if (_format != null) {

@hurelhuyag
Copy link
Author

I realized that there is a good reason why I'd like to see tests: I am pretty sure this will not work for most numeric fields :) Specifically, for int/Integer, long/Long. (I can elaborate why, but it'd be good to add tests first to see if I am right or not).

Why would you think that? Isn't the JDK doc clear?
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/text/NumberFormat.html#format(java.lang.Object,java.lang.StringBuffer,java.text.FieldPosition)

@yihtserns
Copy link
Contributor

Why would you think that? Isn't the JDK doc clear?

I think he's hinting that NumberSerializer does not handle the common & Atomic* number data types - this PR (in the current state) would only likely handle BigDecimal, BigInteger, and other non-common Number subclasses eg LongAdder.

@yihtserns
Copy link
Contributor

Just want to note that current PR covers subset of #1114:

  Serialization Deserialization
int/Integer
long/Long
byte/Byte
short/Short
float/Float
double/Double
BigDecimal
BigInteger
Other Number subclasses e.g. LongAdder
AtomicInteger
AtomicLong

Misc things that we may/may not want to also support:

  • @JsonFormat.lenient
  • @JsonFormat.locale

But I think it's a good start. I do think that using DecimalFormat is better mainly because the same pattern can be used for both formatting AND parsing - I don't think String.format pattern can be used for parsing.

And then I think we need to resolve use of shape wrt values other than STRING / NUMBER / NUMBER_INT / NUMBER_FLOAT. Specially, what to do with ANY / missing.

I'd suggest to only deal with Shape.STRING & (what to do with) Shape.ANY for #1114, to keep things small and focused so they can move faster. There's probably more than enough things to deal with even with just those 2 shapes.

@yihtserns
Copy link
Contributor

yihtserns commented Dec 25, 2023

One quick note: we'd need unit tests to show how this works; class Javadocs would be useful too, to indicate expectation of using format notation of DecimalFormat

Question for core maintainers: where is the doc explaining the kind of @JsonFormat.pattern that is usable for a particular data type?:

Is the full list kept somewhere else, or they have yet to exist?

@cowtowncoder
Copy link
Member

One quick note: we'd need unit tests to show how this works; class Javadocs would be useful too, to indicate expectation of using format notation of DecimalFormat

Question for core maintainers: where is the doc explaining the kind of @JsonFormat.pattern that is usable for a particular data type?:

* I don't see them in the [javadoc of JsonFormat.pattern](https://fasterxml.github.io/jackson-annotations/javadoc/2.14/com/fasterxml/jackson/annotation/JsonFormat.html#pattern--).

* There's only (outdated?) mention of pattern for Date in the [javadoc of JsonFormat](https://fasterxml.github.io/jackson-annotations/javadoc/2.14/com/fasterxml/jackson/annotation/JsonFormat.html).

Is the full list kept somewhere else, or they have yet to exist?

Such a definition does not yet exist. Part of this is the practical challenge of location.

For example, while it would be sort of natural from User POV to find the definition in @JsonFormat Javadocs, this seems bad place from maintenance perspective: actual handling is not implemented by annotation package or annotation types.

But at the same time, finding definitions from -- say -- individual deserializers' Javadocs would not be useful at all.

So I think this should probably be a combination of:

  1. A wiki page (or README.md) in one of repos (jackson, jackson-docs or jackson-databind?)
  2. Links from @JsonFormat Javadocs at least; and maybe from other documentation (cross-refs)

So the first thing would be to decide on (1) I think, and create a skeletal page with some of known cases, general idea behind annotations.

@cowtowncoder
Copy link
Member

I realized that there is a good reason why I'd like to see tests: I am pretty sure this will not work for most numeric fields :) Specifically, for int/Integer, long/Long. (I can elaborate why, but it'd be good to add tests first to see if I am right or not).

Why would you think that? Isn't the JDK doc clear? https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/text/NumberFormat.html#format(java.lang.Object,java.lang.StringBuffer,java.text.FieldPosition)

No, this has nothing to do with Formatter being used, but rather that NumberSerializer is not used by Jackson for most field types. So I don't mean that formatter object could not be used by other number types, but that serializer being used by Jackson varies by type: there are more optimized serializers for primitive types for example.

If you test, for example

public class DoubleWrapper {
   @JsonFormat(...)
   public double value;
}

you will notice that nothing added via NumberSerializer will take effect: NumberSerializer is only used for, I think, BigDecimal and cases where nominal type of field is Number (like, List<Number>).
But if type is declared as double or Double, different serializer is used. Same goes for most or all primitive types.

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.

Support @JsonFormat for String, numbers, using String.format()
4 participants