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

Limitation for Long type finally removed: support for integer types #171

Merged
merged 7 commits into from
Dec 28, 2022
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.akuleshov7.ktoml.decoders

import com.akuleshov7.ktoml.exceptions.CastException
import com.akuleshov7.ktoml.exceptions.IllegalTypeException
import com.akuleshov7.ktoml.tree.nodes.TomlKeyValue
import com.akuleshov7.ktoml.tree.nodes.pairs.values.TomlLong
import com.akuleshov7.ktoml.utils.IntegerLimitsEnum
import com.akuleshov7.ktoml.utils.IntegerLimitsEnum.*
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
Expand All @@ -20,12 +22,12 @@ public abstract class TomlAbstractDecoder : AbstractDecoder() {
private val localDateTimeSerializer = LocalDateTime.serializer()
private val localDateSerializer = LocalDate.serializer()

// Invalid Toml primitive types, we will simply throw an error for them
override fun decodeByte(): Byte = invalidType("Byte", "Long")
override fun decodeShort(): Short = invalidType("Short", "Long")
override fun decodeInt(): Int = invalidType("Int", "Long")
override fun decodeFloat(): Float = invalidType("Float", "Double")
override fun decodeChar(): Char = invalidType("Char", "String")
// Invalid Toml primitive types, but we anyway support them with some limitations
override fun decodeByte(): Byte = decodePrimitiveType()
override fun decodeShort(): Short = decodePrimitiveType()
override fun decodeInt(): Int = decodePrimitiveType()
override fun decodeFloat(): Float = decodePrimitiveType()
override fun decodeChar(): Char = decodePrimitiveType()

// Valid Toml types that should be properly decoded
override fun decodeBoolean(): Boolean = decodePrimitiveType()
Expand All @@ -49,25 +51,62 @@ public abstract class TomlAbstractDecoder : AbstractDecoder() {

internal abstract fun decodeKeyValue(): TomlKeyValue

private fun invalidType(typeName: String, requiredType: String): Nothing {
val keyValue = decodeKeyValue()
throw IllegalTypeException(
"<$typeName> type is not allowed by toml specification," +
" use <$requiredType> instead" +
" (key = ${keyValue.key.content}; value = ${keyValue.value.content})", keyValue.lineNo
)
}

/**
* This is just an adapter from `kotlinx.serialization` to match the content with a type from a Toml Tree,
* that we have parsed to a type that is described in user's code. For example:
* >>> input: a = "5"
* >>> stored in Toml Tree: TomlString("5")
* >>> expected by user: data class A(val a: Int)
* >>> TomlString cannot be cast to Int, user made a mistake -> IllegalTypeException
*/
private inline fun <reified T> decodePrimitiveType(): T {
val keyValue = decodeKeyValue()
try {
return keyValue.value.content as T
return when (val value = keyValue.value) {
is TomlLong -> decodeInteger(value.content as Long, keyValue.lineNo)
else -> keyValue.value.content as T
}
} catch (e: ClassCastException) {
throw CastException(
throw IllegalTypeException(
"Cannot decode the key [${keyValue.key.content}] with the value [${keyValue.value.content}]" +
" with the provided type [${T::class}]. Please check the type in your Serializable class or it's nullability",
keyValue.lineNo
)
}
}

/**
* After a lot of discussions, we have finally decided to allow to use Integer types and not only Long.
orchestr7 marked this conversation as resolved.
Show resolved Hide resolved
* This method does simple validation of integer values to avoid overflow. For example, you really want to use byte,
* we will check here, that your byte value does not exceed 127 and so on.
*/
private inline fun <reified T> decodeInteger(content: Long, lineNo: Int): T = when (T::class) {
Byte::class -> validateAndConvertInt(content, lineNo, BYTE) { num: Long -> num.toByte() as T }
Short::class -> validateAndConvertInt(content, lineNo, SHORT) { num: Long -> num.toShort() as T }
Int::class -> validateAndConvertInt(content, lineNo, INT) { num: Long -> num.toInt() as T }
Long::class -> validateAndConvertInt(content, lineNo, LONG) { num: Long -> num as T }
else -> invalidType(T::class.toString(), "Signed Type")
}

private inline fun <reified T> validateAndConvertInt(
content: Long,
lineNo: Int,
limits: IntegerLimitsEnum,
conversion: (Long) -> T,
): T = if (content in limits.min..limits.max) {
conversion(content)
} else {
Comment on lines +97 to +99
Copy link
Collaborator

@aSemy aSemy Dec 28, 2022

Choose a reason for hiding this comment

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

I think this code is based on my PR which used the MIN/MAX boundaries, but I checked the Kotlinx Serialization JSON decoder and I think it has a better way.

https://github.com/Kotlin/kotlinx.serialization/blob/d7e58c2eeb02e1da87ad36d41ef25523fafb6935/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt#L299-L304

  1. decode to Long
  2. convert to value.toInt()
  3. if the result is different, then throw an exception

It's less code, and I think easier to understand because it's inline, the logic isn't split up or abstracted

Copy link
Owner Author

Choose a reason for hiding this comment

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

The only problem I see here is that comparing operation with limits can be easier than making a toInt() and than comparing... But anyway probably your variant is good enough.

Copy link
Owner Author

Choose a reason for hiding this comment

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

speaking about the idea - I actually asked a question on stack overflow about that and as well as I and you thought - everyone suggests to check boundaries: https://stackoverflow.com/questions/74510016/how-to-understand-if-there-was-an-integer-overflow-during-parsing-of-string-with :D

So probably, it is a good idea to have the most obvious solution

Copy link
Collaborator

Choose a reason for hiding this comment

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

Personally I prefer the KxS JSON way with .toInt(), but the result is the same pick whatever you prefer :)

Copy link
Owner Author

@orchestr7 orchestr7 Dec 28, 2022

Choose a reason for hiding this comment

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

Also funny thing that @NightEule5 suggested same solution as you initially here: #153 (comment) :D

throw IllegalTypeException("The integer literal, that you have provided is <$content>, " +
"but the type for deserialization is <${T::class}>. You will get an overflow, " +
"so we advise you to check the data or use other type for deserialization (Long, for example)", lineNo)
}

private fun invalidType(typeName: String, requiredType: String): Nothing {
val keyValue = decodeKeyValue()
throw IllegalTypeException(
"<$typeName> type is not allowed by toml specification," +
" use <$requiredType> instead" +
" (key = ${keyValue.key.content}; value = ${keyValue.value.content})", keyValue.lineNo
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ internal class NullValueException(propertyName: String, lineNo: Int) : TomlDecod
" Please check the input (line: <$lineNo>) or make the property nullable"
)

internal class CastException(message: String, lineNo: Int) : TomlDecodingException("Line $lineNo: $message")

internal class IllegalTypeException(message: String, lineNo: Int) : TomlDecodingException("Line $lineNo: $message")

internal class MissingRequiredPropertyException(message: String) : TomlDecodingException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* File contains enums with minimum and maximum values of corresponding types
*/

package com.akuleshov7.ktoml.utils

/**
* @property min
* @property max
*/
public enum class IntegerLimitsEnum(public val min: Long, public val max: Long) {
BYTE(Byte.MIN_VALUE.toLong(), Byte.MAX_VALUE.toLong()),
INT(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()),
LONG(Long.MIN_VALUE, Long.MAX_VALUE),
SHORT(Short.MIN_VALUE.toLong(), Short.MAX_VALUE.toLong()),
;
// Unsigned values are not supported now, and I think
// that will not be supported, because TOML spec says the following:
// Arbitrary 64-bit signed integers (from −2^63 to 2^63−1) should be accepted and handled losslessly.
Comment on lines +17 to +19
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would you mind elaborating on why unsigneds can't be supported?

I'd recommend making a new GitHub issue 'support unsigned ints' (I could make the issue if you like) and adding the link in the source code, then it's easier to track and follow progress.

Copy link
Owner Author

@orchestr7 orchestr7 Dec 28, 2022

Choose a reason for hiding this comment

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

I don't mind, but the main problem is related to the kotlinx.serialization. I cannot find decodeUInt in their library...
I will write it to the comments

Copy link
Collaborator

@aSemy aSemy Dec 28, 2022

Choose a reason for hiding this comment

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

Yeah, it's a little bit odd how KxS handles unsigneds.

KxS JSON handles them natively though https://github.com/Kotlin/kotlinx.serialization/blob/v1.4.1/docs/value-classes.md#unsigned-types-support-json-only

I suspect that they should 'just work' though, since they have serializers https://github.com/Kotlin/kotlinx.serialization/blob/v1.4.1/core/commonMain/src/kotlinx/serialization/internal/ValueClasses.kt

How KxS JSON handles them is

  1. unsigned numbers are value classes, so their SerialDescriptors are 'inline'
  2. it keeps a list of the 'SerialDescriptors' for all unsigned numbers
    https://github.com/Kotlin/kotlinx.serialization/blob/dc950f5098162a3f8dd742d04b95f9a0405470e1/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonEncoder.kt#L15-L24
  3. there's a specific function AbstractDecoder.decodeInline(...), that will handle the unsigned numbers
  4. then there's a specific decoder for the unsigned types https://github.com/Kotlin/kotlinx.serialization/blob/d7e58c2eeb02e1da87ad36d41ef25523fafb6935/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt#L367-L379

Copy link
Owner Author

Choose a reason for hiding this comment

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

We will need to add it then also... Yes, let's support it then :)
Looks like a workaround in KxS, looks like they forgot about unsigned in the very beginning

// If an integer cannot be represented losslessly, an error must be thrown. <- FixMe: this is still not supported
// U_BYTE(UByte.MIN_VALUE.toLong(), UByte.MAX_VALUE.toLong()),
// U_SHORT(UShort.MIN_VALUE.toLong(), UShort.MAX_VALUE.toLong()),
// U_INT(UInt.MIN_VALUE.toLong(), UInt.MAX_VALUE.toLong()),
// U_LONG(ULong.MIN_VALUE.toLong(), ULong.MAX_VALUE.toLong()),
}

/**
* @property min
* @property max
*/
public enum class FloatLimitsEnums(public val min: Double, public val max: Double) {
DOUBLE(Double.MIN_VALUE, Double.MAX_VALUE),
FLOAT(Float.MIN_VALUE.toDouble(), Float.MAX_VALUE.toDouble()),
;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.akuleshov7.ktoml.decoders

import com.akuleshov7.ktoml.Toml
import com.akuleshov7.ktoml.exceptions.CastException
import com.akuleshov7.ktoml.exceptions.IllegalTypeException
import com.akuleshov7.ktoml.exceptions.ParseException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.SerialName
Expand Down Expand Up @@ -79,7 +79,7 @@ class SimpleArrayDecoderTest {
val testWithNullArray2: ClassWithMutableList = Toml.decodeFromString("field = null")
assertEquals(null, testWithNullArray2.field)

assertFailsWith<CastException> { Toml.decodeFromString<ClassWithMutableList>("field = [null]").field }
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<ClassWithMutableList>("field = [null]").field }

val testWithOnlyNullInArray: ClassWithImmutableList = Toml.decodeFromString("field = [null ]")
assertEquals(listOf(null), testWithOnlyNullInArray.field)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.akuleshov7.ktoml.decoders

import com.akuleshov7.ktoml.Toml
import com.akuleshov7.ktoml.exceptions.CastException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.akuleshov7.ktoml.decoders

import com.akuleshov7.ktoml.Toml
import com.akuleshov7.ktoml.exceptions.IllegalTypeException
import com.akuleshov7.ktoml.exceptions.CastException
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlin.test.Test
Expand Down Expand Up @@ -44,9 +43,9 @@ class DecodingTypeTest {
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<I>("a = true") }
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<C>("a = true") }

assertFailsWith<CastException> { Toml.decodeFromString<Bool>("a = \"test\"") }
assertFailsWith<CastException> { Toml.decodeFromString<Str>("a = true") }
assertFailsWith<CastException> { Toml.decodeFromString<L>("a = 12.0") }
assertFailsWith<CastException> { Toml.decodeFromString<D>("a = 1") }
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<Bool>("a = \"test\"") }
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<Str>("a = true") }
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<L>("a = 12.0") }
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<D>("a = 1") }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.akuleshov7.ktoml.decoders

import com.akuleshov7.ktoml.Toml
import com.akuleshov7.ktoml.exceptions.IllegalTypeException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.Serializable
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.serialization.ExperimentalSerializationApi
import kotlin.test.assertFailsWith

class IntegersDecoderTest {
@Serializable
data class Integers(
val s: Short,
val b: Byte,
val i: Int,
val l: Long,
)

@Test
fun positiveScenario() {
var test = """
s = 5
b = 5
i = 5
l = 5
""".trimMargin()

var decoded = Toml.decodeFromString<Integers>(test)
println(decoded)
assertEquals(
Integers(5, 5, 5, 5),
decoded
)

test = """
s = 32767
b = -128
i = 5
l = 5
""".trimMargin()

decoded = Toml.decodeFromString(test)
println(decoded)
assertEquals(
Integers(32767, -128, 5, 5),
decoded
)
}

@Test
fun negativeScenario() {
var test = """
s = 32768
b = 5
i = 5
l = 5
""".trimMargin()
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<Integers>(test) }

test = """
s = -32769
b = 5
i = 5
l = 5
""".trimMargin()
assertFailsWith<IllegalTypeException> { Toml.decodeFromString<Integers>(test) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ class ReadMeExampleTest {
data class Table1(
// nullable values, from toml you can pass null/nil/empty value to this kind of a field
val property1: Long?,
// please note, that according to the specification of toml integer values should be represented with Long
val property2: Long,
// no need to pass this value as it has the default value and is NOT REQUIRED
val property3: Long = 5
// please note, that according to the specification of toml integer values should be represented with Long,
// but we allow to use Int/Short/etc. Just be careful with overflow
val property2: Int,
// no need to pass this value in the input as it has the default value and so it is NOT REQUIRED
val property3: Short = 5
)

@Serializable
Expand Down