Repository navigation
FFL-3565: Accept every numeric type in Int and Double reads #4099
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
base: develop
Are you sure you want to change the base?
Changes from all commits
6c1181d
e5cd008
64be54d
c90d1c3
61f8293
689a921
7e5ab28
8018347
07e625d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ package com.datadog.android.flags.internal | |
|
|
||
| import com.datadog.android.flags.internal.model.VariationType | ||
| import org.json.JSONObject | ||
| import java.math.BigDecimal | ||
| import java.util.Locale | ||
| import kotlin.reflect.KClass | ||
|
|
||
|
|
@@ -28,6 +29,16 @@ internal class TypeMismatchException(message: String) : Exception(message) | |
| */ | ||
| internal object FlagValueConverter { | ||
|
|
||
| private val INT_MIN = BigDecimal(Int.MIN_VALUE) | ||
| private val INT_MAX = BigDecimal(Int.MAX_VALUE) | ||
|
|
||
| // The longest decimal value that Int reads, and Double reads at or above 2^53, parse with BigDecimal. | ||
| // This bounds the BigDecimal work on each read. | ||
| private const val MAX_DECIMAL_LENGTH = 64 | ||
|
|
||
| // 2^53. Below this magnitude, Double holds every whole value exactly. | ||
| private const val EXACT_DOUBLE_LIMIT = 9_007_199_254_740_992.0 | ||
|
|
||
| /** | ||
| * Converts a flag's string value to the expected type. | ||
| * | ||
|
|
@@ -36,7 +47,9 @@ internal object FlagValueConverter { | |
| * @param variationType The type specified in the flag metadata | ||
| * @param targetType The target type class to convert to | ||
| * @return Result containing the converted value on success, | ||
| * or a Failure with TypeMismatchException if types are incompatible, | ||
| * or a Failure with TypeMismatchException if types are incompatible | ||
| * or a numeric value can't be read as Int without loss, | ||
| * or a whole value matches neither the Double nor its printed form, | ||
| * or a Failure with the parse exception if conversion failed | ||
| */ | ||
| @Suppress("UNCHECKED_CAST") | ||
|
|
@@ -53,8 +66,8 @@ internal object FlagValueConverter { | |
| val result: T? = when (targetType) { | ||
| Boolean::class -> variationValue.lowercase(Locale.US).toBooleanStrictOrNull() as? T | ||
| String::class -> variationValue as T | ||
| Int::class -> variationValue.toIntOrNull() as? T | ||
| Double::class -> variationValue.toDoubleOrNull() as? T | ||
| Int::class -> parseLosslessInt(variationValue, variationType) as? T | ||
| Double::class -> parseLosslessDouble(variationValue, variationType) as? T | ||
|
aarsilv marked this conversation as resolved.
Resolved
|
||
| Map::class -> variationValue.toMap() as? T | ||
| JSONObject::class -> { | ||
| @Suppress("UnsafeThirdPartyFunctionCall") // Safe: wrapped in runCatching | ||
|
|
@@ -88,10 +101,7 @@ internal object FlagValueConverter { | |
| fun isTypeCompatible(variationType: String, targetType: KClass<*>): Boolean = when (targetType) { | ||
| Boolean::class -> variationType == VariationType.BOOLEAN.value | ||
| String::class -> variationType == VariationType.STRING.value | ||
| Int::class -> variationType == VariationType.INTEGER.value || variationType == VariationType.NUMBER.value | ||
| Double::class -> | ||
| variationType == VariationType.NUMBER.value || variationType == VariationType.FLOAT.value || | ||
| variationType == VariationType.INTEGER.value | ||
| Int::class, Double::class -> isNumeric(variationType) | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Context from Claude: Int reads now accept all three numeric types. A |
||
| JSONObject::class -> variationType == VariationType.OBJECT.value | ||
| Map::class -> variationType == VariationType.OBJECT.value | ||
| else -> { | ||
|
|
@@ -115,4 +125,90 @@ internal object FlagValueConverter { | |
| Map::class -> "Map" | ||
| else -> targetType.simpleName ?: "Unknown" | ||
| } | ||
|
|
||
| private fun isNumeric(variationType: String): Boolean = variationType == VariationType.INTEGER.value || | ||
| variationType == VariationType.NUMBER.value || | ||
| variationType == VariationType.FLOAT.value | ||
|
|
||
| /** | ||
| * Int reads are lossless: a whole value within Int range, such as `5` or `5.0`, reads as Int whatever the | ||
| * numeric type. A fractional or out-of-range value is a type mismatch. A value that can't be parsed returns null, | ||
| * which [convert] reports as a parse error. So does a value that is not a plain Int and is longer than | ||
| * [MAX_DECIMAL_LENGTH] characters. | ||
| */ | ||
| @Throws(TypeMismatchException::class) | ||
| private fun parseLosslessInt(variationValue: String, variationType: String): Int? = variationValue.toIntOrNull() | ||
| ?: variationValue.takeIf { it.length <= MAX_DECIMAL_LENGTH } | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Context from Claude: Plain integers take the fast |
||
| ?.toBigDecimalOrNull() | ||
| ?.let { requireLosslessInt(it, variationValue, variationType) } | ||
|
|
||
| // Compare with the Int bounds instead of calling BigDecimal.intValueExact(): on Android 9, intValueExact() | ||
| // expands the power of ten, so a short value such as `1e10000000` takes seconds. | ||
| @Throws(TypeMismatchException::class) | ||
| private fun requireLosslessInt(decimal: BigDecimal, variationValue: String, variationType: String): Int { | ||
| val problem = when { | ||
| isFractional(decimal) -> "fractional value '$variationValue'" | ||
| decimal < INT_MIN || decimal > INT_MAX -> "value '$variationValue' outside Int range" | ||
| else -> null | ||
| } | ||
| if (problem != null) { | ||
| @Suppress("ThrowingInternalException") // Caught by runCatching in convert() | ||
| throw TypeMismatchException("Flag has type '$variationType' with $problem but Int was requested") | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Context from Claude: Parsing succeeded before this branch. A fractional value, or a whole value outside
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. worth to mark with method with @throws annotation to no forget to wrap this method in a future changes. Then suppressing wouldn't be needed
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Reply from Claude, working with Aaron: Good idea. 7e5ab28 adds The |
||
| } | ||
| return decimal.toInt() | ||
| } | ||
|
|
||
| /** | ||
| * A whole value reads as Double only when the Double equals it, or when the Double prints as the same value, | ||
| * such as `1e300`. So `9007199254740993` is a type mismatch. A fractional value reads as the nearest Double, | ||
| * such as `1.2`. A value that can't be parsed returns null, which [convert] reports as a parse error. So does | ||
| * a value whose parsed Double is 2^53 or more in magnitude, when the value is longer than [MAX_DECIMAL_LENGTH] | ||
| * characters or [BigDecimal] can't parse it. | ||
| */ | ||
| @Throws(TypeMismatchException::class) | ||
| private fun parseLosslessDouble(variationValue: String, variationType: String): Double? = | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Context from Claude: This makes Double reads match the shared fixtures for large whole values. |
||
| variationValue.toDoubleOrNull()?.let { double -> | ||
| if (double > -EXACT_DOUBLE_LIMIT && double < EXACT_DOUBLE_LIMIT) { | ||
| double | ||
| } else { | ||
| variationValue.takeIf { it.length <= MAX_DECIMAL_LENGTH } | ||
| ?.toBigDecimalOrNull() | ||
| ?.let { requireLosslessDouble(it, double, variationValue, variationType) } | ||
| } | ||
| } | ||
|
|
||
| @Throws(TypeMismatchException::class) | ||
| private fun requireLosslessDouble( | ||
| decimal: BigDecimal, | ||
| double: Double, | ||
| variationValue: String, | ||
| variationType: String | ||
| ): Double { | ||
| if (!isFractional(decimal) && !isExactDouble(decimal, double)) { | ||
| @Suppress("ThrowingInternalException") // Caught by runCatching in convert() | ||
| throw TypeMismatchException( | ||
| "Flag has type '$variationType' with whole value '$variationValue' that Double can't hold exactly " + | ||
| "but Double was requested" | ||
| ) | ||
| } | ||
| return double | ||
| } | ||
|
|
||
| @Suppress("UnsafeThirdPartyFunctionCall") // Safe: BigDecimal(Double) and valueOf() only run for a finite Double | ||
| private fun isExactDouble(decimal: BigDecimal, double: Double): Boolean = | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Context from Claude: A whole value also passes when the |
||
| double != Double.POSITIVE_INFINITY && double != Double.NEGATIVE_INFINITY && | ||
| (decimal.compareTo(BigDecimal(double)) == 0 || decimal.compareTo(BigDecimal.valueOf(double)) == 0) | ||
|
|
||
| /** | ||
| * A value with a scale of 0 or less is whole. Check the scale first: it also prevents | ||
| * [BigDecimal.stripTrailingZeros] from overflowing the scale for a value such as `100e2147483647`. | ||
| * Zero is whole at any scale: some older Android versions return a zero such as `0.0` unchanged from | ||
| * [BigDecimal.stripTrailingZeros]. | ||
| */ | ||
| private fun isFractional(decimal: BigDecimal): Boolean { | ||
| if (decimal.scale() <= 0 || decimal.signum() == 0) return false | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Context from Claude: |
||
| @Suppress("UnsafeThirdPartyFunctionCall") // Safe: the scale is positive, so it can't overflow | ||
| val stripped = decimal.stripTrailingZeros() | ||
| return stripped.scale() > 0 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 Context from Claude: The Int conversion now runs inside
runCatching. If the decimal path throws an unexpected exception, the client returns the default value withPARSE_ERROR. The exception does not reach the host app.