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 option to preserve zero decimals #831

Merged
merged 19 commits into from
Jan 17, 2023

Conversation

trbogart
Copy link

@trbogart trbogart commented Nov 22, 2022

Pull Request Checklist

  • Updated documentation in code
  • Have you added tests for any changed functionality?

Fixes

Fixes #107 by adding a configuration option to preserve zero decimal numbers from being rounded to a whole number (for example, 1.0 will not be converted to 1).

In particular, we want to use this to prevent ElasticSearch from creating a long dynamic mapping for a field that should be a double. It's not really a bug (as described in that issue), so the old behavior is preserved by default.

My first approach was to serialize this in our code base. That works, but isn't ideal. It required copying a fair bit of play-json code to override just a single line of code. Also, our code serializes Json in dozens of places, all of which had to be changed to use the new methods.

Purpose

This adds an option to preserve a .0 for decimal numbers that are rounded to a whole number (e.g. 1.0 to 1). This distinction is important in some situations, such as ES dynamic mapping or KairosDB schema creation.

Background Context

It might be harmless to do the new behavior by default, but the original issue did mention a preference for opting in to this behavior, which is reasonable.

References

Are there any relevant issues / PRs / mailing lists discussions?
#107

@@ -69,6 +69,16 @@ private[jackson] class JsValueSerializer(parserSettings: JsonParserSettings) ext
import com.fasterxml.jackson.databind.node.BigIntegerNode
import com.fasterxml.jackson.databind.node.DecimalNode

private def stripTrailingZeros(bigDec: JBigDec): JBigDec = {
Copy link
Author

Choose a reason for hiding this comment

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

This is the core change.

@trbogart trbogart force-pushed the preserveZeroDecimal branch from 38b3e53 to b4d0b00 Compare November 22, 2022 23:10
@gmethvin
Copy link
Member

@cchantep Thoughts? The general approach seems reasonable to me.

@trbogart
Copy link
Author

I'm not sure why mima is still failing. I'll have to come back to that next week.

Copy link
Member

@gmethvin gmethvin left a comment

Choose a reason for hiding this comment

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

MiMa is failing because you've changed the case class constructor to have 3 arguments rather than 2, which also affects the apply and copy methods generated by the case class.

Arguably it was a bad idea to make JsonParserSettings a case class in #191, as it makes it inconvenient to add new settings to the case class without breaking compatibility. We can still technically work around this by manually adding copy, apply and this methods with the old arguments, but it ends up being pretty ugly, and we will have to do this again every time we add another field to the case class.

So a few options are:

  1. Add in the extra methods for binary compatibility—these could be made private[json] so they are only visible publicly in the binary representation.
  2. Option 1, plus we make both the constructor and copy method private, so in the future when adding config we only need to worry about the apply method.
  3. Switch to using a (preferably sealed) trait for configuration, deprecate the old case class config, and create a new way to construct instance of the trait for config.
  4. Wait to introduce this breaking change in the next "major" version of play-json—which I think would be 2.10.0, and is coming up pretty soon.

Option 2 seems like the best balance between amount of work now and extensibility in the future.

* Copyright (C) 2009-2021 Lightbend Inc. <https://www.lightbend.com>
*/

package play.api.libs.json
Copy link
Author

Choose a reason for hiding this comment

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

Renamed from JsonParserSettings.

I ended up using sealed traits, which I called JsonConfig, BigDecimalParseConfig, and BigDecimalSerializerConfig.

@trbogart trbogart requested a review from cchantep December 5, 2022 23:17
bigDecimalSerializerConfig: BigDecimalSerializerConfig
) extends JsonConfig

@deprecated("Use BigDecimalParseConfig instead", "2.10.0")
Copy link
Author

Choose a reason for hiding this comment

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

What should I use for the 2nd argument of these deprecated annotations?

* val jsValue = mapper.readValue("""{"foo":"bar"}""", classOf[JsValue])
* }}}
*/
sealed class PlayJsonModule(parserSettings: JsonParserSettings)
Copy link
Author

Choose a reason for hiding this comment

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

This is the only public usage of JsonParserSettings. I deprecated this and replaced it with PlayJsonMapperModule (I'm open to naming suggestions).

import java.math.BigInteger
import java.math.{ BigDecimal => JBigDec }

import com.fasterxml.jackson.databind.node.BigIntegerNode
import com.fasterxml.jackson.databind.node.DecimalNode

private def stripTrailingZeros(bigDec: JBigDec): JBigDec = {
val stripped = bigDec.stripTrailingZeros
if (jsonConfig.bigDecimalSerializerConfig.preserveZeroDecimal && bigDec.scale > 0 && stripped.scale <= 0) {
Copy link
Author

Choose a reason for hiding this comment

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

stripTrailingZeros can drop the scale below 0 (e.g. "10.0" will end up with a scale of -1, which needs to be corrected to 1). I also added some additional unit tests to cover this case.

@gmethvin
Copy link
Member

@trbogart thanks for the configuration changes. @cchantep what do you think about this approach?

@trbogart trbogart removed the request for review from cchantep January 3, 2023 22:41
@trbogart trbogart requested a review from gmethvin January 3, 2023 22:41
@trbogart
Copy link
Author

trbogart commented Jan 3, 2023

@gmethvin and @cchantep, this is ready for review again.

private lazy val mapper = (new ObjectMapper).registerModule(new PlayJsonModule(JsonParserSettings.settings))
val defaultInstance: JacksonJson = JacksonJson(JsonConfig.settings)

private val ref: AtomicReference[JacksonJson] = new AtomicReference[JacksonJson](defaultInstance)
Copy link
Member

Choose a reason for hiding this comment

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

So it add overhead for all runtime whereas it's only useful for testing?
Couldn't the internal set logic and its synchronization be only in tests?

Copy link
Member

Choose a reason for hiding this comment

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

I agree if this is only for tests it probably doesn't need to be atomic. This is an internal API so we can control how it's used.

Also wondering if we could keep JacksonJson an object and just have a setConfig in there?

Copy link
Author

Choose a reason for hiding this comment

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

I'll remove the atomic. I kept the case class as a convenience to initialize the mapper and factory together.

Copy link
Author

Choose a reason for hiding this comment

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

Having the case class also means that any given operations will use a consistent set of mapper and factory, so there's not really a need for synchronization. Running tests concurrently could still give unexpected results (since it relies on static state), but the atomic reference didn't actually help with that anyway.

*/
sealed trait BigDecimalParseConfig {

/**
Copy link
Author

Choose a reason for hiding this comment

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

I added more documentation for the configuration

@trbogart trbogart requested a review from cchantep January 12, 2023 23:07
@gmethvin
Copy link
Member

@cchantep do you have any other concerns or are we good to merge this?

@trbogart
Copy link
Author

Actually, there is another thing before merging. I added some deprecated annotations, and I wasn't sure what to use for the "since" field. I used "2.10.0", but I'm not sure if that is correct.

Also, what is the timeline for getting this released?

Thanks!

@gmethvin and @cchantep

@mkurz
Copy link
Member

mkurz commented Jan 12, 2023

Also, what is the timeline for getting this released?

I didn't follow this conversations, however if @gmethvin and @cchantep merge this I can take over and cut a new release afterwards.

@gmethvin
Copy link
Member

I think we want to backport this to 2.9.x, so deprecating in 2.9.4 would make sense? I believe play-json 2.9.x is usable with Play 2.8?

@mkurz
Copy link
Member

mkurz commented Jan 13, 2023

I think we want to backport this to 2.9.x, so deprecating in 2.9.4 would make sense? I believe play-json 2.9.x is usable with Play 2.8?

Right now Play 2.8 ships with play-json 2.8.x, however I think it should be possible to manually upgrade to play-json 2.9.x, I think some people did that already, not sure though.

@gmethvin gmethvin merged commit 4a8a215 into playframework:main Jan 17, 2023
trbogart pushed a commit to trbogart/play-json that referenced this pull request Jan 17, 2023
Co-authored-by: Cédric Chantepie <cchantep@users.noreply.github.com>
@trbogart
Copy link
Author

2.8.x backport PR: #840
2.9.x backport PR: #839

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.

Double values with 0 fraction written without fraction
4 participants