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

Adds reader for valid_values #265

Merged
merged 1 commit into from
May 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.amazon.ionschema.model

import com.amazon.ion.IonNumber
import java.math.BigDecimal

/**
* Wrapper for BigDecimal where [equals], [hashCode], and [compareTo] are all consistent.
*/
class ConsistentDecimal(val bigDecimalValue: BigDecimal) : Comparable<ConsistentDecimal> {

private val normalized: BigDecimal = bigDecimalValue.stripTrailingZeros()

override fun compareTo(other: ConsistentDecimal) = normalized.compareTo(other.normalized)

override fun equals(other: Any?) = other is ConsistentDecimal && normalized == other.normalized

override fun hashCode() = normalized.hashCode()

override fun toString(): String {
return "ConsistentDecimal(${normalized.unscaledValue()}E${normalized.scale() * -1})"
}

companion object {
/**
* Constructs a new [ConsistentDecimal] with the value from the given [IonNumber].
*/
@JvmStatic
fun fromIonNumber(ionNumber: IonNumber) = ConsistentDecimal(ionNumber.bigDecimalValue())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.amazon.ionschema.model

import com.amazon.ion.IonTimestamp
import com.amazon.ion.Timestamp
import java.math.BigDecimal

/**
* Wrapper for Timestamp where [equals], [hashCode], and [compareTo] are all consistent.
*/
class ConsistentTimestamp(val timestampValue: Timestamp) : Comparable<ConsistentTimestamp> {

private val normalizedMillis: BigDecimal = timestampValue.decimalMillis.stripTrailingZeros()

override fun compareTo(other: ConsistentTimestamp) = normalizedMillis.compareTo(other.normalizedMillis)

override fun equals(other: Any?) = other is ConsistentTimestamp && normalizedMillis == other.normalizedMillis

override fun hashCode() = normalizedMillis.hashCode()

override fun toString(): String {
return "ConsistentTimestamp($timestampValue)"
}

companion object {
/**
* Constructs a new [ConsistentTimestamp] with the value from the given [IonTimestamp].
*/
@JvmStatic
fun fromIonTimestamp(ionTimestamp: IonTimestamp) = ConsistentTimestamp(ionTimestamp.timestampValue())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.amazon.ion.IonNumber
import com.amazon.ion.IonValue
import com.amazon.ion.Timestamp
import com.amazon.ionschema.util.Bag
import java.math.BigDecimal

/**
* Convenience alias for a collections of open content fields in schema headers, footers, and type definitions.
Expand All @@ -22,14 +21,14 @@ typealias OpenContentFields = Bag<Pair<String, IonValue>>
typealias TypeArgumentList = List<TypeArgument>

/**
* A [ContinuousRange] of [Timestamp].
* A [ContinuousRange] of [Timestamp], represented as a [ConsistentTimestamp].
*/
typealias TimestampRange = ContinuousRange<Timestamp>
typealias TimestampRange = ContinuousRange<ConsistentTimestamp>

/**
* A [ContinuousRange] of [IonNumber], represented as [BigDecimal]
* A [ContinuousRange] of [IonNumber], represented as [ConsistentDecimal]
*/
typealias NumberRange = ContinuousRange<BigDecimal>
typealias NumberRange = ContinuousRange<ConsistentDecimal>

/**
* A [ContinuousRange] of [TimestampPrecisionValue].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import com.amazon.ionschema.reader.internal.constraints.LogicConstraintsReader
import com.amazon.ionschema.reader.internal.constraints.OrderedElementsReader
import com.amazon.ionschema.reader.internal.constraints.PrecisionReader
import com.amazon.ionschema.reader.internal.constraints.RegexReader
import com.amazon.ionschema.reader.internal.constraints.ValidValuesReader
import com.amazon.ionschema.util.toBag

@ExperimentalIonSchemaModel
Expand All @@ -48,6 +49,7 @@ internal class TypeReaderV2_0 : TypeReader {
OrderedElementsReader(this),
PrecisionReader(),
RegexReader(IonSchemaVersion.v2_0),
ValidValuesReader(),
)

override fun readNamedTypeDefinition(context: ReaderContext, ion: IonValue): NamedTypeDefinition {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.amazon.ionschema.reader.internal.constraints

import com.amazon.ion.IonList
import com.amazon.ion.IonNumber
import com.amazon.ion.IonTimestamp
import com.amazon.ion.IonValue
import com.amazon.ionschema.InvalidSchemaException
import com.amazon.ionschema.internal.util.islRequireIonTypeNotNull
import com.amazon.ionschema.internal.util.islRequireNoIllegalAnnotations
import com.amazon.ionschema.model.Constraint
import com.amazon.ionschema.model.ExperimentalIonSchemaModel
import com.amazon.ionschema.model.ValidValue
import com.amazon.ionschema.reader.internal.ReaderContext
import com.amazon.ionschema.reader.internal.invalidConstraint
import com.amazon.ionschema.reader.internal.toNumberRange
import com.amazon.ionschema.reader.internal.toTimestampRange

@ExperimentalIonSchemaModel
internal class ValidValuesReader : ConstraintReader {

override fun canRead(fieldName: String): Boolean = fieldName == "valid_values"

override fun readConstraint(context: ReaderContext, field: IonValue): Constraint {
check(canRead(field.fieldName))

islRequireIonTypeNotNull<IonList>(field) { invalidConstraint(field, "must be a non-null list") }
islRequireNoIllegalAnnotations(field, "range") { invalidConstraint(field, "must be a range or an unannotated list of values ") }
val theList = if (field.hasTypeAnnotation("range")) listOf(field) else field

val theValidValues = theList.map {
if (it.hasTypeAnnotation("range") && it is IonList) {
when {
it.any { x -> x is IonTimestamp } -> ValidValue.IonTimestampRange(it.toTimestampRange())
it.any { x -> x is IonNumber } -> ValidValue.IonNumberRange(it.toNumberRange())
else -> throw InvalidSchemaException("Not a valid range: $it")
}
} else {
islRequireNoIllegalAnnotations(it) { invalidConstraint(it, "annotations not permitted except for range") }
ValidValue.Value(it.clone())
}
}

return Constraint.ValidValues(theValidValues)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.amazon.ionschema.reader.internal

import com.amazon.ion.IonInt
import com.amazon.ion.IonList
import com.amazon.ion.IonNumber
import com.amazon.ion.IonSymbol
import com.amazon.ion.IonValue
import com.amazon.ionschema.InvalidSchemaException
Expand All @@ -10,7 +11,41 @@ import com.amazon.ionschema.internal.util.islRequireExactAnnotations
import com.amazon.ionschema.internal.util.islRequireIonNotNull
import com.amazon.ionschema.internal.util.islRequireIonTypeNotNull
import com.amazon.ionschema.internal.util.islRequireNoIllegalAnnotations
import com.amazon.ionschema.model.ConsistentDecimal
import com.amazon.ionschema.model.ConsistentTimestamp
import com.amazon.ionschema.model.ContinuousRange
import com.amazon.ionschema.model.DiscreteIntRange
import com.amazon.ionschema.model.NumberRange
import com.amazon.ionschema.model.TimestampRange

/**
* Converts an [IonValue] to a [TimestampRange].
*/
internal fun IonValue.toTimestampRange(): TimestampRange = toContinuousRange(ConsistentTimestamp::fromIonTimestamp)

/**
* Converts an [IonValue] to a [NumberRange].
*/
internal fun IonValue.toNumberRange(): NumberRange = toContinuousRange { it: IonNumber ->
islRequire(it.isNumericValue) { "Invalid number range; range bounds must be real numbers: $this" }
ConsistentDecimal.fromIonNumber(it)
}

/**
* Converts an [IonValue] to a [ContinuousRange] using the given [valueFn].
*/
private inline fun <T : Comparable<T>, reified IV : IonValue> IonValue.toContinuousRange(valueFn: (IV) -> T): ContinuousRange<T> {
return when (this) {
is IonList -> {
islRequire(size == 2) { "Invalid range; size of list must be 2: $this" }
islRequireExactAnnotations(this, "range") { "Invalid range; missing 'range' annotation: $this" }
val lower = readContinuousRangeBoundary(BoundaryPosition.Lower, valueFn)
val upper = readContinuousRangeBoundary(BoundaryPosition.Upper, valueFn)
ContinuousRange(lower, upper)
}
else -> throw InvalidSchemaException("Invalid range; not an ion list: $this")
}
}

/**
* Converts an [IonValue] to a [DiscreteIntRange]
Expand All @@ -22,7 +57,7 @@ internal fun IonValue.toDiscreteIntRange(): DiscreteIntRange {
islRequireExactAnnotations(this, "range") { "Invalid range; missing 'range' annotation: $this" }
val lower = readDiscreteIntRangeBoundary(BoundaryPosition.Lower)
val upper = readDiscreteIntRangeBoundary(BoundaryPosition.Upper)
return DiscreteIntRange(lower, upper)
DiscreteIntRange(lower, upper)
}
is IonInt -> {
islRequireIonNotNull(this) { "Range cannot be a null value" }
Expand Down Expand Up @@ -56,6 +91,25 @@ private fun IonList.readDiscreteIntRangeBoundary(bp: BoundaryPosition): Int? {
}
}

/**
* Reads and validates a single endpoint of a continuous value range.
*/
private inline fun <T : Comparable<T>, reified IV : IonValue> IonList.readContinuousRangeBoundary(boundaryPosition: BoundaryPosition, valueFn: (IV) -> T): ContinuousRange.Limit<T> {
val b = get(boundaryPosition.idx) ?: throw InvalidSchemaException("Invalid range; missing $boundaryPosition boundary value: $this")
return if (b is IonSymbol && b.stringValue() == boundaryPosition.symbol) {
islRequire(b.typeAnnotations.isEmpty()) { "Invalid range; min/max may not be annotated: $this" }
ContinuousRange.Limit.Unbounded()
} else {
val value = islRequireIonTypeNotNull<IV>(b) { "Invalid range; $boundaryPosition boundary of range must be '${boundaryPosition.symbol}' or a non-null ${IV::class.simpleName}" }.let(valueFn)
val exclusive = readBoundaryExclusivity(boundaryPosition)
if (exclusive) {
ContinuousRange.Limit.Open(value)
} else {
ContinuousRange.Limit.Closed(value)
}
}
}

/**
* Reads and validates annotations on the endpoint of a range, returning true iff the endpoint is exclusive.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.amazon.ionschema.model

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.math.BigDecimal

class ConsistentDecimalTest {

@Test
fun `compareTo, hashCode, and equals are consistent for fractional numbers with different precision`() {
val a = ConsistentDecimal(BigDecimal("20.100000"))
val b = ConsistentDecimal(BigDecimal("20.1"))

assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
assertEquals(0, a.compareTo(b))
assertEquals("$a", "$b")
println("$a == $b")
}

@Test
fun `compareTo, hashCode, and equals are consistent for integers with different precision`() {
val a = ConsistentDecimal(BigDecimal("10.00000"))
val b = ConsistentDecimal(BigDecimal("10"))

assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
assertEquals(0, a.compareTo(b))
assertEquals("$a", "$b")
println("$a == $b")
}

@Test
fun `compareTo, hashCode, and equals are consistent for zeros with different precision`() {
val a = ConsistentDecimal(BigDecimal("0.00000"))
val b = ConsistentDecimal(BigDecimal("0"))

assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
assertEquals(0, a.compareTo(b))
assertEquals("$a", "$b")
println("$a == $b")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.amazon.ionschema.model

import com.amazon.ion.Timestamp
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class ConsistentTimestampTest {

@Test
fun `compareTo, hashCode, and equals are consistent when precision is different`() {
val a = ConsistentTimestamp(Timestamp.valueOf("2022-01-01T00:00Z"))
val b = ConsistentTimestamp(Timestamp.valueOf("2022-01-01T00:00:00.000Z"))

assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
assertEquals(0, a.compareTo(b))
println("$a == $b")
}

@Test
fun `compareTo, hashCode, and equals are consistent when offset is different`() {
val a = ConsistentTimestamp(Timestamp.valueOf("2022-01-01T04:30:00+04:30"))
val b = ConsistentTimestamp(Timestamp.valueOf("2022-01-01T00:00:00Z"))

assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
assertEquals(0, a.compareTo(b))
println("$a == $b")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class ReaderTests {
"contains",
"timestamp_offset",
"timestamp_precision",
"valid_values",
)
val unimplementedConstraintsRegex = Regex("constraints/(${unimplementedConstraints.joinToString("|")})")

Expand Down Expand Up @@ -103,7 +102,7 @@ class ReaderTestsRunner(
val cases = (ion["invalid_types"] as IonList).mapIndexed { i, invalidType ->
val displayName = "[$relativeFile] $baseDescription [$i]"
DynamicTest.dynamicTest(displayName) {
assertThrows<InvalidSchemaException> { reader.readTypeOrThrow(invalidType) }
assertThrows<InvalidSchemaException>("invalid type: $invalidType") { reader.readTypeOrThrow(invalidType) }
}
}
DynamicContainer.dynamicContainer("[$relativeFile] $baseDescription", cases)
Expand Down
Loading