From 7843d99c355b585ea969c682b7225f9f6c717a05 Mon Sep 17 00:00:00 2001 From: Matthew Pope Date: Fri, 14 Apr 2023 17:16:05 -0700 Subject: [PATCH] Adds ISL model --- .../ionschema/internal/constraint/Regex.kt | 156 +----------- .../amazon/ionschema/internal/util/regex.kt | 157 ++++++++++++ .../com/amazon/ionschema/model/Constraint.kt | 235 ++++++++++++++++++ .../amazon/ionschema/model/ContinuousRange.kt | 95 +++++++ .../amazon/ionschema/model/DiscreteRange.kt | 50 ++++ .../model/ExperimentalIonSchemaModel.kt | 5 + .../amazon/ionschema/model/HeaderImport.kt | 16 ++ .../model/Ieee754InterchangeFormat.kt | 37 +++ .../ionschema/model/NamedTypeDefinition.kt | 7 + .../amazon/ionschema/model/SchemaDocument.kt | 40 +++ .../ionschema/model/TimestampOffsetValue.kt | 52 ++++ .../model/TimestampPrecisionValue.kt | 41 +++ .../amazon/ionschema/model/TypeArgument.kt | 46 ++++ .../amazon/ionschema/model/TypeDefinition.kt | 17 ++ .../ionschema/model/UserReservedFields.kt | 11 + .../com/amazon/ionschema/model/ValidValue.kt | 28 +++ .../model/VariablyOccurringTypeArgument.kt | 16 ++ .../com/amazon/ionschema/model/aliases.kt | 36 +++ .../ionschema/model/ContinuousRangeTest.kt | 154 ++++++++++++ .../ionschema/model/DiscreteRangeTest.kt | 97 ++++++++ .../model/Ieee754InterchangeFormatTest.kt | 45 ++++ .../ionschema/model/SchemaDocumentTest.kt | 147 +++++++++++ .../model/TimestampOffsetValueTest.kt | 68 +++++ .../model/TimestampPrecisionValueTest.kt | 51 ++++ 24 files changed, 1454 insertions(+), 153 deletions(-) create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/regex.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/Constraint.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/ContinuousRange.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/DiscreteRange.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/ExperimentalIonSchemaModel.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/HeaderImport.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/Ieee754InterchangeFormat.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/NamedTypeDefinition.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/SchemaDocument.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/TimestampOffsetValue.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/TimestampPrecisionValue.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/TypeArgument.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/TypeDefinition.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/UserReservedFields.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/ValidValue.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/VariablyOccurringTypeArgument.kt create mode 100644 ion-schema/src/main/kotlin/com/amazon/ionschema/model/aliases.kt create mode 100644 ion-schema/src/test/kotlin/com/amazon/ionschema/model/ContinuousRangeTest.kt create mode 100644 ion-schema/src/test/kotlin/com/amazon/ionschema/model/DiscreteRangeTest.kt create mode 100644 ion-schema/src/test/kotlin/com/amazon/ionschema/model/Ieee754InterchangeFormatTest.kt create mode 100644 ion-schema/src/test/kotlin/com/amazon/ionschema/model/SchemaDocumentTest.kt create mode 100644 ion-schema/src/test/kotlin/com/amazon/ionschema/model/TimestampOffsetValueTest.kt create mode 100644 ion-schema/src/test/kotlin/com/amazon/ionschema/model/TimestampPrecisionValueTest.kt diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/constraint/Regex.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/constraint/Regex.kt index 1a7952df..db733097 100644 --- a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/constraint/Regex.kt +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/constraint/Regex.kt @@ -23,6 +23,7 @@ import com.amazon.ionschema.IonSchemaVersion import com.amazon.ionschema.Violation import com.amazon.ionschema.Violations import com.amazon.ionschema.internal.util.islRequire +import com.amazon.ionschema.internal.util.validateRegexPattern import java.util.regex.Pattern /** @@ -59,8 +60,8 @@ internal class Regex( } flags = flags.or(flag) } - - pattern = toPattern(ion.stringValue(), flags) + val patternString = validateRegexPattern(ion.stringValue(), islVersion) + pattern = Pattern.compile(patternString, flags) } override fun validate(value: IonValue, issues: Violations) { @@ -75,155 +76,4 @@ internal class Regex( } } } - - private fun toPattern(string: String, flags: Int): Pattern { - val si = StringIterator(string) - val sb = StringBuilder() - var ch = si.next() - do { - when (ch) { - '[' -> { - sb.append(ch) - parseCharacterClass(si, sb) - } - '(' -> { - sb.append(ch) - ch = si.next() - if (ch == '?') { // error on "(?..." constructs - error(si, "invalid character '$ch'") - } - sb.append(ch) - } - '\\' -> { // handle escaped chars - ch = si.next() - when (ch) { - '.', '^', '$', '|', '?', '*', '+', '\\', - '[', ']', '(', ')', '{', '}', - 'w', 'W', 'd', 'D' -> sb.append('\\').append(ch) - 's' -> sb.append("[ \\f\\n\\r\\t]") - 'S' -> sb.append("[^ \\f\\n\\r\\t]") - else -> error(si, "invalid escape character '$ch'") - } - } - else -> sb.append(ch) // otherwise, accept the character - } - - parseQuantifier(si, sb) // parse a quantifier, if present - - ch = si.next() - } while (ch != null) - - return Pattern.compile(sb.toString(), flags) - } - - private fun parseCharacterClass(si: StringIterator, sb: StringBuilder) { - do { - val ch = si.next() - sb.append(ch) - - when (ch) { - '&' -> { - if (si.peek() == '&') { - error(si, "'&&' is not supported in a character class") - } - } - - '[' -> error(si, "'[' must be escaped within a character class") - - '\\' -> { - when (val ch2 = si.next()) { - '[', ']', '\\' -> sb.append(ch2) - 'd', 's', 'w', 'D', 'S', 'W' -> if (islVersion == IonSchemaVersion.v1_0) { - // For Ion Schema 1.0, this is an error because ISL 1.0 does - // not support pre-defined char classes (i.e., \d, \s, \w) - // while user is specifying a new char class - error(si, "invalid sequence '\\$ch2' in character class") - } else { - // In Ion Schema 2.0, this is allowed - sb.append(ch2) - } - else -> error( - si, - "invalid sequence '\\$ch2' in character class" - ) - } - } - - ']' -> return - } - } while (ch != null) - - error(si, "character class missing ']'") - } - - private fun parseQuantifier(si: StringIterator, sb: StringBuilder) { - val initialLength = sb.length - var ch = si.peek() - when (ch) { - '?', '*', '+' -> { - ch = si.next() - sb.append(ch) - } - '{' -> { - ch = si.next() - sb.append(ch) - var complete = false - // A quantifier such as {,3} is not an ECMA 262 quantifier (it has no lower bound) - // We track whether we've found a number so that we can ensure that a comma is only - // allowed if it follows at least one digit. - var foundAnyNumber = false - do { - ch = si.next() - when (ch) { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> { sb.append(ch); foundAnyNumber = true } - ',' -> if (foundAnyNumber) sb.append(ch) else error(si, "range quantifier is missing lower bound") - '}' -> { - sb.append(ch) - complete = true - } - null -> {} - else -> error(si, "invalid character '$ch'") - } - } while (ch != null && !complete) - - if (!complete) { - error(si, "range quantifier missing '}'") - } - } - } - - if (sb.length > initialLength && ch != null) { - ch = si.peek() - when (ch) { - '?' -> error(si, "invalid character '$ch'") - '+' -> error(si, "invalid character '$ch'") - } - } - } - - private fun error(si: StringIterator, message: String): Unit = - throw InvalidSchemaException("$message in regex '$si' at offset ${si.currentIndex()}") -} - -private class StringIterator(private val s: String) { - private var index = -1 - val length = s.length - - fun next(): Char? { - index += 1 - return get(index) - } - - fun peek() = get(index + 1) - - private fun get(i: Int): Char? { - if (i < length) { - return s[i] - } - return null - } - - fun currentIndex() = index - - override fun toString() = s } diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/regex.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/regex.kt new file mode 100644 index 00000000..fe51c0cb --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/regex.kt @@ -0,0 +1,157 @@ +package com.amazon.ionschema.internal.util + +import com.amazon.ionschema.InvalidSchemaException +import com.amazon.ionschema.IonSchemaVersion + +/** + * Validates that a regex pattern is valid for the subset of ECMA-262 that is supported by Ion Schema, and converts + * to the equivalent syntax for Java Regex. + */ +internal fun validateRegexPattern(string: String, islVersion: IonSchemaVersion = IonSchemaVersion.v2_0): String { + val si = StringIterator(string) + val sb = StringBuilder() + var ch = si.next() + do { + when (ch) { + '[' -> { + sb.append(ch) + parseCharacterClass(si, sb, islVersion) + } + '(' -> { + sb.append(ch) + ch = si.next() + if (ch == '?') { // error on "(?..." constructs + error(si, "invalid character '$ch'") + } + sb.append(ch) + } + '\\' -> { // handle escaped chars + ch = si.next() + when (ch) { + '.', '^', '$', '|', '?', '*', '+', '\\', + '[', ']', '(', ')', '{', '}', + 'w', 'W', 'd', 'D' -> sb.append('\\').append(ch) + 's' -> sb.append("[ \\f\\n\\r\\t]") + 'S' -> sb.append("[^ \\f\\n\\r\\t]") + else -> error(si, "invalid escape character '$ch'") + } + } + else -> sb.append(ch) // otherwise, accept the character + } + + parseQuantifier(si, sb) // parse a quantifier, if present + + ch = si.next() + } while (ch != null) + + return sb.toString() // Pattern.compile(sb.toString(), flags) +} + +private fun parseCharacterClass(si: StringIterator, sb: StringBuilder, islVersion: IonSchemaVersion) { + do { + val ch = si.next() + sb.append(ch) + + when (ch) { + '&' -> { + if (si.peek() == '&') { + error(si, "'&&' is not supported in a character class") + } + } + + '[' -> error(si, "'[' must be escaped within a character class") + + '\\' -> { + when (val ch2 = si.next()) { + '[', ']', '\\' -> sb.append(ch2) + 'd', 's', 'w', 'D', 'S', 'W' -> if (islVersion == IonSchemaVersion.v1_0) { + // For Ion Schema 1.0, this is an error because ISL 1.0 does + // not support pre-defined char classes (i.e., \d, \s, \w) + // while user is specifying a new char class + error(si, "invalid sequence '\\$ch2' in character class") + } else { + // In Ion Schema 2.0, this is allowed + sb.append(ch2) + } + else -> error( + si, + "invalid sequence '\\$ch2' in character class" + ) + } + } + + ']' -> return + } + } while (ch != null) + + error(si, "character class missing ']'") +} +private fun parseQuantifier(si: StringIterator, sb: StringBuilder) { + val initialLength = sb.length + var ch = si.peek() + when (ch) { + '?', '*', '+' -> { + ch = si.next() + sb.append(ch) + } + '{' -> { + ch = si.next() + sb.append(ch) + var complete = false + // A quantifier such as {,3} is not an ECMA 262 quantifier (it has no lower bound) + // We track whether we've found a number so that we can ensure that a comma is only + // allowed if it follows at least one digit. + var foundAnyNumber = false + do { + ch = si.next() + when (ch) { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> { sb.append(ch); foundAnyNumber = true } + ',' -> if (foundAnyNumber) sb.append(ch) else error(si, "range quantifier is missing lower bound") + '}' -> { + sb.append(ch) + complete = true + } + null -> {} + else -> error(si, "invalid character '$ch'") + } + } while (ch != null && !complete) + + if (!complete) { + error(si, "range quantifier missing '}'") + } + } + } + + if (sb.length > initialLength && ch != null) { + ch = si.peek() + when (ch) { + '?' -> error(si, "invalid character '$ch'") + '+' -> error(si, "invalid character '$ch'") + } + } +} +private fun error(si: StringIterator, message: String): Unit = + throw InvalidSchemaException("$message in regex '$si' at offset ${si.currentIndex()}") + +private class StringIterator(private val s: String) { + private var index = -1 + val length = s.length + + fun next(): Char? { + index += 1 + return get(index) + } + + fun peek() = get(index + 1) + + private fun get(i: Int): Char? { + if (i < length) { + return s[i] + } + return null + } + + fun currentIndex() = index + + override fun toString() = s +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/Constraint.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/Constraint.kt new file mode 100644 index 00000000..63658642 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/Constraint.kt @@ -0,0 +1,235 @@ +package com.amazon.ionschema.model + +import com.amazon.ion.IonValue +import com.amazon.ionschema.IonSchemaVersion +import com.amazon.ionschema.internal.util.validateRegexPattern +import kotlin.text.Regex as KRegex + +/** + * Marker interface for all Constraint implementations. + * + * This implementation of Ion Schema does not support custom constraints. Do not implement this interface. + */ +@ExperimentalIonSchemaModel +interface Constraint { + + /** + * Represents the `all_of` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#all_of) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#all_of). + */ + data class AllOf(val types: TypeArgumentList) : Constraint + + /** + * Represents the `annotations` constraint for Ion Schema 1.0. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#annotations). + */ + data class AnnotationsV1(val annotations: List, val closed: Boolean, val ordered: Boolean) : + Constraint { + class Annotation(val text: String, val required: Boolean) + } + + /** + * Represents the `annotations` constraint from Ion Schema 2.0 onwards. + * See relevant section in [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#annotations). + */ + data class AnnotationsV2(val type: TypeArgument) : Constraint + + /** + * Represents the `any_of` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#any_of) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#any_of). + */ + data class AnyOf(val types: TypeArgumentList) : Constraint + + /** + * Represents the `byte_length` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#byte_length) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#byte_length). + */ + data class ByteLength(val range: DiscreteIntRange) : Constraint { + init { + range.start?.let { require(it >= 0) } + } + } + + /** + * Represents the `codepoint_length` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#codepoint_length) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#codepoint_length). + */ + data class CodepointLength(val range: DiscreteIntRange) : Constraint { + init { + range.start?.let { require(it >= 0) } + } + } + + /** + * Represents the `container_length` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#container_length) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#container_length). + */ + data class ContainerLength(val range: DiscreteIntRange) : Constraint { + init { + range.start?.let { require(it >= 0) } + } + } + + /** + * Represents the `contains` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#contains) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#contains). + */ + data class Contains(val values: List) : Constraint + + /** + * Represents the `element` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#element) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#element). + * + * The [distinct] modifier is only supported for Ion Schema 2.0 and higher. + */ + data class Element(val type: TypeArgument, val distinct: Boolean = false) : Constraint + + /** + * Represents the `exponent` constraint, introduced in Ion Schema 2.0. + * See relevant section in [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#exponent). + */ + data class Exponent(val range: DiscreteIntRange) : Constraint + + /** + * Represents the `field_names` constraint, introduced in Ion Schema 2.0. + * See relevant section in [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#field_names). + */ + data class FieldNames(val type: TypeArgument, val distinct: Boolean = false) : Constraint + + /** + * Represents the `fields` constraint. + * Beware when reading from ISL—the ISL representation of [closed] was changed between ISL 1.0 and ISL 2.0. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#fields) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#fields). + */ + data class Fields(val fields: Map, val closed: Boolean) : Constraint + + /** + * Represents the `ieee754_float` constraint, introduced in Ion Schema 2.0. + * See relevant section in [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#ieee754_float). + */ + data class Ieee754Float(val format: Ieee754InterchangeFormat) : Constraint + + /** + * Represents the `not` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#not) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#not). + */ + data class Not(val type: TypeArgument) : Constraint + + /** + * Represents the `one_of` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#one_of) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#one_of). + */ + data class OneOf(val types: TypeArgumentList) : Constraint + + /** + * Represents the `ordered_elements` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#ordered_elements) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#ordered_elements). + */ + data class OrderedElements(val types: List) : Constraint + + /** + * Represents the `precision` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#precision) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#precision). + */ + data class Precision(val range: DiscreteIntRange) : Constraint { + init { + range.start?.let { require(it >= 1) } + } + } + + /** + * Represents the `regex` constraint. + * + * Ion Schema regular expressions are a subset of ECMA-262. + * The allowed subset of ECMA-262 regular expressions was changed between ISL 1.0 and ISL 2.0. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#regex) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#regex). + * + * See also [_Comparison of regular expression engines_](https://en.wikipedia.org/wiki/Comparison_of_regular_expression_engines#Language_features) + * for the high-level differences between ECMA (JavaScript) and Java regular expressions. + */ + data class Regex( + val pattern: String, + val caseInsensitive: Boolean = false, + val multiline: Boolean = false, + val ionSchemaVersion: IonSchemaVersion = IonSchemaVersion.v2_0, + ) : Constraint { + internal val compiled = run { + require(pattern.isNotEmpty()) + val opts = mutableSetOf() + if (caseInsensitive) opts.add(RegexOption.IGNORE_CASE) + if (multiline) opts.add(RegexOption.MULTILINE) + KRegex(validateRegexPattern(pattern, ionSchemaVersion), opts) + } + } + + /** + * Represents the `scale` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#scale). + * + * This constraint was replaced by `exponent` in Ion Schema 2.0. + * To convert to [Exponent], used [Scale.toExponentConstraint]. + */ + data class Scale(val range: DiscreteIntRange) : Constraint { + fun toExponentConstraint() { + Exponent(range.negate()) + } + } + + /** + * Represents the `timestamp_offset` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#timestamp_offset) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#timestamp_offset). + * + * @see TimestampOffsetValue + */ + data class TimestampOffset(val offsets: List) : Constraint + + /** + * Represents the `timestamp_precision` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#timestamp_precision) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#timestamp_precision). + * + * @see [TimestampPrecisionRange] + */ + data class TimestampPrecision(val range: TimestampPrecisionRange) : Constraint + + /** + * Represents the `type` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#type) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#type). + */ + data class Type(val type: TypeArgument) : Constraint + + /** + * Represents the `utf8_byte_length` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#utf8_byte_length) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#utf8_byte_length). + */ + data class Utf8ByteLength(val range: DiscreteIntRange) : Constraint { + init { + range.start?.let { require(it >= 0) } + } + } + + /** + * Represents the `valid_values` constraint. + * See relevant section in [ISL 1.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#valid_values) and + * [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#valid_values). + * + * @see ValidValue + */ + data class ValidValues(val values: List) : Constraint +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ContinuousRange.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ContinuousRange.kt new file mode 100644 index 00000000..72d791e3 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ContinuousRange.kt @@ -0,0 +1,95 @@ +package com.amazon.ionschema.model + +/** + * A range over a type that is an uncountably infinite set. + * + * A `ContinuousRange` is a _bounded interval_ when both limits are non-null, and it is a _half-bounded interval_ + * when one of the limits is `null`. At least one of [start] and [end] must be non-null. + * + * A `ContinuousRange` can be _open_, _half-open_, or _closed_ depending on [Limit.exclusive] of the limits. + * + * A `ContinuousRange` is allowed to be a _degenerate interval_ (i.e. `start == end` when both limits are closed), + * but it may not be an empty interval (i.e. `start == end` when either limit is open, or `start > end`). + */ +data class ContinuousRange>(val start: Limit, val end: Limit) { + + sealed class Limit> { + abstract val value: T? + + interface Bounded { val value: T } + data class Closed>(override val value: T) : Limit(), Bounded + data class Open>(override val value: T) : Limit(), Bounded + class Unbounded> : Limit() { + override val value: Nothing? get() = null + + override fun equals(other: Any?) = other is Unbounded<*> + override fun hashCode() = 0 + } + } + + init { + require(start is Limit.Bounded<*> || end is Limit.Bounded<*>) { "range may not be unbounded both above and below" } + require(!isEmpty(start, end)) { "range may not be empty" } + } + + /** + * Returns the intersection of `this` `DiscreteRange` with [other]. + * If the two ranges do not intersect, returns `null`. + */ + fun intersect(that: ContinuousRange): ContinuousRange? { + val newStart = when { + this.start is Limit.Unbounded -> that.start + that.start is Limit.Unbounded -> this.start + this.start.value!! > that.start.value!! -> this.start + that.start.value!! > this.start.value!! -> that.start + this.start is Limit.Open -> this.start + that.start is Limit.Open -> that.start + else -> this.start // They are both closed and equal + } + val newEnd = when { + this.end is Limit.Unbounded -> that.end + that.end is Limit.Unbounded -> this.end + this.end.value!! < that.end.value!! -> this.end + that.end.value!! < this.end.value!! -> that.end + this.end is Limit.Open -> this.end + that.end is Limit.Open -> that.end + else -> this.end // They are both closed and equal + } + return if (isEmpty(newStart, newEnd)) null else ContinuousRange(newStart, newEnd) + } + + /** + * Checks whether the given value is contained within this range. + */ + operator fun contains(value: T): Boolean = start.isBelow(value) && end.isAbove(value) + + private fun Limit.isAbove(other: T) = when (this) { + is Limit.Closed -> value >= other + is Limit.Open -> value > other + is Limit.Unbounded -> true + } + + private fun Limit.isBelow(other: T) = when (this) { + is Limit.Closed -> value <= other + is Limit.Open -> value < other + is Limit.Unbounded -> true + } + + /** + * Checks whether the range is empty. The range is empty if its start value is greater than the end value for + * non-exclusive endpoints, or if the start value equals the end value when either endpoint is exclusive. + */ + private fun isEmpty(start: Limit, end: Limit): Boolean { + if (start is Limit.Unbounded || end is Limit.Unbounded) return false + val exclusive = start is Limit.Open || end is Limit.Open + return if (exclusive) start.value!! >= end.value!! else start.value!! > end.value!! + } + + override fun toString(): String { + val lowerBrace = if (start is Limit.Closed) '[' else '(' + val lowerValue = start.value ?: " " + val upperValue = end.value ?: " " + val upperBrace = if (end is Limit.Closed) ')' else ')' + return "$lowerBrace$lowerValue,$upperValue$upperBrace" + } +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/DiscreteRange.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/DiscreteRange.kt new file mode 100644 index 00000000..8f9f4e20 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/DiscreteRange.kt @@ -0,0 +1,50 @@ +package com.amazon.ionschema.model + +/** + * A range over a type that is a finite or countably infinite set. The values contained in the range include both of the + * limits—[start] and [endInclusive]. + * + * A `DiscreteRange` is a _bounded, closed interval_ when both limits are non-null, and it is a _half-bounded interval_ + * when one of the limits is `null`. At least one of [start] and [endInclusive] must be non-null. + * + * A `DiscreteRange` is allowed to be a _degenerate interval_ (i.e. `start == endInclusive`), but it may not be an + * empty interval (i.e. `start > endInclusive`). + */ +class DiscreteIntRange private constructor(private val delegate: ContinuousRange) { + + // Because we never construct Open bounds, we cannot accidentally create an empty range. + constructor(start: Int?, endInclusive: Int?) : this( + ContinuousRange( + start?.let { ContinuousRange.Limit.Closed(it) } ?: ContinuousRange.Limit.Unbounded(), + endInclusive?.let { ContinuousRange.Limit.Closed(it) } ?: ContinuousRange.Limit.Unbounded() + ) + ) + + val start: Int? + get() = (delegate.start as? ContinuousRange.Limit.Closed)?.value + + val endInclusive: Int? + get() = (delegate.end as? ContinuousRange.Limit.Closed)?.value + + /** + * Negates the boundaries of the range, and swaps their position to keep the lower number in the `start` position. + * + * For example: + * ``` + * val range1 = DiscreteIntRange(-2, 10) + * val range2 = DiscreteIntRange(-10, 2) + * assertEquals(range1.negate(), range2) + * ``` + */ + fun negate() = DiscreteIntRange(delegate.end.value?.let { it * -1 }, delegate.start.value?.let { it * -1 }) + fun intersect(other: DiscreteIntRange): DiscreteIntRange? = delegate.intersect(other.delegate)?.let { DiscreteIntRange(it) } + + operator fun contains(value: Int): Boolean = delegate.contains(value) + override fun toString() = delegate.toString() + + override fun hashCode(): Int = delegate.hashCode() + override fun equals(other: Any?): Boolean = other is DiscreteIntRange && other.delegate == this.delegate + + operator fun component1() = start + operator fun component2() = endInclusive +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ExperimentalIonSchemaModel.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ExperimentalIonSchemaModel.kt new file mode 100644 index 00000000..1535fec8 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ExperimentalIonSchemaModel.kt @@ -0,0 +1,5 @@ +package com.amazon.ionschema.model + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.TYPEALIAS) +@RequiresOptIn +annotation class ExperimentalIonSchemaModel diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/HeaderImport.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/HeaderImport.kt new file mode 100644 index 00000000..28877596 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/HeaderImport.kt @@ -0,0 +1,16 @@ +package com.amazon.ionschema.model + +/** + * Represents an import in the schema header. + */ +@ExperimentalIonSchemaModel +sealed class HeaderImport { + /** + * An import that imports all types from a schema + */ + data class Wildcard(val id: String) : HeaderImport() + /** + * An import of a specific type from a schema, with an optional alias. + */ + data class Type(val id: String, val targetType: String, val asType: String? = null) : HeaderImport() +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/Ieee754InterchangeFormat.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/Ieee754InterchangeFormat.kt new file mode 100644 index 00000000..d19d3956 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/Ieee754InterchangeFormat.kt @@ -0,0 +1,37 @@ +package com.amazon.ionschema.model + +/** + * Represents the IEEE-754 interchange formats supported by the `ieee754_float` constraint. + * See [`ieee754_float`](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#ieee754_float) in the ISL 2.0 specification. + * See also [IEEE-754](https://en.wikipedia.org/wiki/IEEE_754) on Wikipedia for more information about the different formats. + */ +enum class Ieee754InterchangeFormat { + Binary16, + Binary32, + Binary64; + + /** + * The symbol text for this value as defined in the ISL specification. + */ + val symbolText = name.toLowerCase() + + companion object { + /** + * Returns the [Ieee754InterchangeFormat] corresponding to `text`, or `null` if `text` does not correspond to any value. + */ + @JvmStatic + fun fromSymbolTextOrNull(text: String): Ieee754InterchangeFormat? { + return values().firstOrNull { it.symbolText == text } + } + + /** + * Returns the [Ieee754InterchangeFormat] corresponding to `text`. + * @throws IllegalArgumentException if `text` does not correspond to any value. + */ + @JvmStatic + fun fromSymbolText(text: String): Ieee754InterchangeFormat { + return fromSymbolTextOrNull(text) + ?: throw IllegalArgumentException("'$text' is not a supported ieee754 format value") + } + } +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/NamedTypeDefinition.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/NamedTypeDefinition.kt new file mode 100644 index 00000000..b4a444da --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/NamedTypeDefinition.kt @@ -0,0 +1,7 @@ +package com.amazon.ionschema.model + +/** + * Represents a top-level, named type definition. + */ +@ExperimentalIonSchemaModel +data class NamedTypeDefinition(val typeName: String, val typeDefinition: TypeDefinition) diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/SchemaDocument.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/SchemaDocument.kt new file mode 100644 index 00000000..8ca3b3a9 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/SchemaDocument.kt @@ -0,0 +1,40 @@ +package com.amazon.ionschema.model + +import com.amazon.ion.IonValue +import com.amazon.ionschema.IonSchemaVersion + +/** + * Represents an Ion Schema document. + */ +@ExperimentalIonSchemaModel +data class SchemaDocument( + val id: String?, + val items: List +) { + val ionSchemaVersion: IonSchemaVersion = items.firstOrNull { it !is Item.OpenContent } + ?.let { it as? Item.VersionMarker } + ?.value + ?: IonSchemaVersion.v1_0 + val header: Item.Header? = items.filterIsInstance().singleOrNull() + val footer: Item.Footer? = items.filterIsInstance().singleOrNull() + val declaredTypes: List = items.filterIsInstance().map { it.value } + + /** + * Represents a top-level item in a schema document. + */ + sealed class Item { + data class VersionMarker(val value: IonSchemaVersion) : Item() + + data class Type(val value: NamedTypeDefinition) : Item() + + data class Header( + val imports: List = emptyList(), + val userReservedFields: UserReservedFields = UserReservedFields(), + val openContent: OpenContentFields = emptyList() + ) : Item() + + data class Footer(val openContent: OpenContentFields = emptyList()) : Item() + + data class OpenContent(val value: IonValue) : Item() + } +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TimestampOffsetValue.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TimestampOffsetValue.kt new file mode 100644 index 00000000..2fabf9fe --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TimestampOffsetValue.kt @@ -0,0 +1,52 @@ +package com.amazon.ionschema.model + +import com.amazon.ion.Timestamp +import kotlin.math.abs + +/** + * Represents the offset of a [Timestamp] to be used as an argument for the `timestamp_offset` constraint. + * @see Constraint.TimestampOffset + */ +sealed class TimestampOffsetValue { + object Unknown : TimestampOffsetValue() { + override fun toString(): String = "-00:00" + } + data class Known(val minutes: Int) : TimestampOffsetValue() { + init { + require(minutes in VALID_MINUTES_RANGE) { "timestamp offset cannot be more than 23h59m from zero" } + } + override fun toString(): String = "%s%02d:%02d".format(if (minutes < 0) '-' else '+', abs(minutes / 60), abs(minutes % 60)) + } + + companion object { + private val REGEX = Regex("^[+-](2[0-3]|[01]\\d):[0-5]\\d$") + private val VALID_MINUTES_RANGE = -1439..1439 // ±23h59m + private val HOURS_SLICE = 1..2 + private val MINUTES_SLICE = 4..5 + + /** + * Parses a timestamp offset string into a [TimestampOffsetValue]. + */ + @JvmStatic + fun parse(string: String): TimestampOffsetValue { + require(REGEX.matches(string)) { "timestamp offset value '$string' does not match required format '$REGEX'" } + return if (string == "-00:00") { + Unknown + } else { + val sign = if (string[0] == '-') -1 else 1 + val hours = string.slice(HOURS_SLICE).toInt() + val minutes = string.slice(MINUTES_SLICE).toInt() + Known(sign * (hours * 60 + minutes)) + } + } + + /** + * Creates a [TimestampOffsetValue] for the given number of minutes. + * If minutes is `null`, returns [TimestampOffsetValue.Unknown]. + */ + @JvmStatic + fun fromMinutes(minutes: Int?): TimestampOffsetValue { + return minutes?.let { Known(it) } ?: Unknown + } + } +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TimestampPrecisionValue.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TimestampPrecisionValue.kt new file mode 100644 index 00000000..1c67a3fc --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TimestampPrecisionValue.kt @@ -0,0 +1,41 @@ +package com.amazon.ionschema.model + +/** + * Represents the available arguments for the `timestamp_precision` constraint. + * @see Constraint.TimestampPrecision + */ +enum class TimestampPrecisionValue(private val id: Int) { + Year(-4), + Month(-3), + Day(-2), + // hour (without minute) is not supported by Ion + Minute(-1), + Second(0), + Millisecond(3), + Microsecond(6), + Nanosecond(9); + + /** + * The symbol text for this value as defined in the ISL specification. + */ + val symbolText = name.toLowerCase() + + companion object { + /** + * Returns the [TimestampPrecisionValue] corresponding to `text`, or `null` if `text` does not correspond to any value. + */ + @JvmStatic + fun fromSymbolTextOrNull(text: String): TimestampPrecisionValue? { + return values().firstOrNull { it.symbolText == text } + } + + /** + * Returns the [TimestampPrecisionValue] corresponding to `text`. + * @throws IllegalArgumentException if `text` does not correspond to any value. + */ + @JvmStatic + fun fromSymbolText(text: String): TimestampPrecisionValue { + return fromSymbolTextOrNull(text) ?: throw IllegalArgumentException("'$text' is not a timestamp precision value") + } + } +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TypeArgument.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TypeArgument.kt new file mode 100644 index 00000000..a4999453 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TypeArgument.kt @@ -0,0 +1,46 @@ +package com.amazon.ionschema.model + +/** + * A TypeArgument represents (defines or references) a Type, allowing the Type to be used as an argument for a constraint. + * + * **_Do not implement this interface_**—It will become a sealed interface once `ion-schema-kotlin` is updated to use + * language version 1.5. + */ +@ExperimentalIonSchemaModel +sealed class TypeArgument { + abstract val nullability: Nullability + + /** + * Nullability modifiers for [TypeArgument]. + */ + enum class Nullability { + /** + * No special treatment of null values. + */ + None, + /** + * Ion Schema 1.0 nullability. See https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#core-types + */ + Nullable, + /** + * Ion Schema 2.0+ `$null_or` annotation. See https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#nullable-type-arguments + */ + OrNull, + } + + /** + * [TypeArgument] that is an anonymous type, defined inline. + */ + class InlineType(val typeDefinition: TypeDefinition, override val nullability: Nullability = Nullability.None) : TypeArgument() + + /** + * A [TypeArgument] that references another type by [typeName] only. + * This can refer to any types that are defined in the same schema, imported via the schema header, or any built-in types. + */ + class Reference(val typeName: String, override val nullability: Nullability = Nullability.None) : TypeArgument() + + /** + * A [TypeArgument] that references a type from a different schema by [schemaId] and [typeName]. + */ + class Import(val schemaId: String, val typeName: String, override val nullability: Nullability = Nullability.None) : TypeArgument() +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TypeDefinition.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TypeDefinition.kt new file mode 100644 index 00000000..d28402f2 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/TypeDefinition.kt @@ -0,0 +1,17 @@ +package com.amazon.ionschema.model + +/** + * Represents the common fields of all type definitions; used to compose [NamedTypeDefinition] and [TypeArgument.InlineType]. + */ +@ExperimentalIonSchemaModel +class TypeDefinition(val constraints: List, val openContent: OpenContentFields = emptyList()) { + override fun equals(other: Any?): Boolean { + return other is TypeDefinition && + this.constraints == other.constraints && + this.openContent == other.openContent + } + + override fun hashCode(): Int { + return constraints.hashCode() * 31 + openContent.hashCode() + } +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/UserReservedFields.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/UserReservedFields.kt new file mode 100644 index 00000000..4b72139c --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/UserReservedFields.kt @@ -0,0 +1,11 @@ +package com.amazon.ionschema.model + +/** + * The collection of field names that are reserved by the user as open content fields. + * See relevant section in [ISL 2.0 spec](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#open-content). + */ +data class UserReservedFields( + val type: List = emptyList(), + val header: List = emptyList(), + val footer: List = emptyList(), +) diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ValidValue.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ValidValue.kt new file mode 100644 index 00000000..2baa0033 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/ValidValue.kt @@ -0,0 +1,28 @@ +package com.amazon.ionschema.model + +import com.amazon.ion.IonValue + +/** + * Represents an argument to the `valid_values` constraint. + * @see [Constraint.ValidValues] + */ +sealed class ValidValue { + /** + * A single Ion value. May not be annotated. + * Ignoring annotations, this value is compared using Ion equivalence with data that is being validated. + * @see [Constraint.ValidValues] + */ + class Value(val value: IonValue) : ValidValue() { + init { require(value.typeAnnotations.isEmpty()) { "valid value may not be annotated" } } + } + + /** + * A range of numbers. + */ + class IonNumberRange(val range: NumberRange) : ValidValue() + + /** + * A range of timestamp values. + */ + class IonTimestampRange(val range: TimestampRange) : ValidValue() +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/VariablyOccurringTypeArgument.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/VariablyOccurringTypeArgument.kt new file mode 100644 index 00000000..99b80aac --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/VariablyOccurringTypeArgument.kt @@ -0,0 +1,16 @@ +package com.amazon.ionschema.model + +/** + * Represents a Type as a constraint argument with additional information about the number of times this type can occur. + * See [Type Definitions](https://amazon-ion.github.io/ion-schema/docs/isl-1-0/spec#type-definitions) in ISL 1.0 spec. + * See [Variably Occurring Type Arguments](https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#variably-occurring-type-arguments) in ISL 2.0 spec. + */ +@ExperimentalIonSchemaModel +data class VariablyOccurringTypeArgument(val occurs: DiscreteIntRange, val typeArg: TypeArgument) { + companion object { + @JvmStatic + val OCCURS_OPTIONAL = DiscreteIntRange(0, 1) + @JvmStatic + val OCCURS_REQUIRED = DiscreteIntRange(1, 1) + } +} diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/model/aliases.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/aliases.kt new file mode 100644 index 00000000..883bf5a7 --- /dev/null +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/model/aliases.kt @@ -0,0 +1,36 @@ +package com.amazon.ionschema.model + +import com.amazon.ion.IonNumber +import com.amazon.ion.IonValue +import com.amazon.ion.Timestamp +import java.math.BigDecimal + +/** + * Convenience alias for a collections of open content fields in schema headers, footers, and type definitions. + */ +typealias OpenContentFields = List> + +/** + * Convenience alias for a list of [TypeArgument]. + */ +@ExperimentalIonSchemaModel +typealias TypeArgumentList = List + +/** + * A [ContinuousRange] of [Timestamp]. + */ +typealias TimestampRange = ContinuousRange + +/** + * A [ContinuousRange] of [IonNumber], represented as [BigDecimal] + */ +typealias NumberRange = ContinuousRange + +/** + * A [ContinuousRange] of [TimestampPrecisionValue]. + * `TimestampPrecision` is a discrete measurement (i.e. there is no fractional number of digits of precision). + * However, because Ion Schema models timestamp precision as an enum, there are possible precisions that exist between + * the available enum values. For example, `timestamp_precision: range::[exclusive::second, exclusive::millisecond]` + * allows 1 or 2 digits of precision for the fractional seconds of a timestamp. + */ +typealias TimestampPrecisionRange = ContinuousRange diff --git a/ion-schema/src/test/kotlin/com/amazon/ionschema/model/ContinuousRangeTest.kt b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/ContinuousRangeTest.kt new file mode 100644 index 00000000..99445d78 --- /dev/null +++ b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/ContinuousRangeTest.kt @@ -0,0 +1,154 @@ +package com.amazon.ionschema.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(Lifecycle.PER_CLASS) +class ContinuousRangeTest { + + @TestFactory + fun testConstructingValidRanges() = listOf( + closed(0.0) to closed(0.0), + closed(0.0) to closed(1.0), + open(0.0) to closed(1.0), + closed(0.0) to open(1.0), + open(0.0) to open(1.0), + closed(0.0) to unbounded(), + open(0.0) to unbounded(), + unbounded() to closed(1.0), + unbounded() to open(1.0), + ).map { (min, max) -> "[constructor] ContinuousRange($min, $max) should be valid" { ContinuousRange(min, max) } } + + fun invalidRanges() = listOf( + open(0.0) to closed(0.0), + closed(0.0) to open(0.0), + open(0.0) to open(0.0), + unbounded() to unbounded(), + closed(0.0) to closed(-1.0), + open(0.0) to closed(-1.0), + closed(0.0) to open(-1.0), + open(0.0) to open(-1.0), + ).map { arguments(it.first, it.second) } + @ParameterizedTest(name = "[constructor] ContinuousRange({0}, {1}) should not be valid") + @MethodSource("invalidRanges") + fun `ContinuousRange should be invalid`(min: ContinuousRange.Limit, max: ContinuousRange.Limit) { + assertThrows { ContinuousRange(min, max) } + } + + @TestFactory + fun testContains() = listOf( + case(ContinuousRange(closed(2.0), closed(4.0)), value = 1.0, isContained = false), + case(ContinuousRange(closed(2.0), closed(4.0)), value = 2.0, isContained = true), + case(ContinuousRange(closed(2.0), closed(4.0)), value = 3.0, isContained = true), + case(ContinuousRange(closed(2.0), closed(4.0)), value = 4.0, isContained = true), + case(ContinuousRange(closed(2.0), closed(4.0)), value = 5.0, isContained = false), + case(ContinuousRange(unbounded(), closed(4.0)), value = -99.0, isContained = true), + case(ContinuousRange(unbounded(), closed(4.0)), value = -1.0, isContained = true), + case(ContinuousRange(unbounded(), closed(4.0)), value = 0.0, isContained = true), + case(ContinuousRange(unbounded(), closed(4.0)), value = 4.0, isContained = true), + case(ContinuousRange(unbounded(), closed(4.0)), value = 5.0, isContained = false), + case(ContinuousRange(closed(-3.0), unbounded()), value = -99.0, isContained = false), + case(ContinuousRange(closed(-3.0), unbounded()), value = -3.0, isContained = true), + case(ContinuousRange(closed(-3.0), unbounded()), value = 0.0, isContained = true), + case(ContinuousRange(closed(-3.0), unbounded()), value = 4.0, isContained = true), + case(ContinuousRange(closed(-3.0), unbounded()), value = 99.0, isContained = true), + case(ContinuousRange(closed(8.0), closed(8.0)), value = 7.0, isContained = false), + case(ContinuousRange(closed(8.0), closed(8.0)), value = 8.0, isContained = true), + case(ContinuousRange(closed(8.0), closed(8.0)), value = 9.0, isContained = false), + case(ContinuousRange(open(2.0), open(4.0)), value = 1.0, isContained = false), + case(ContinuousRange(open(2.0), open(4.0)), value = 2.0, isContained = false), + case(ContinuousRange(open(2.0), open(4.0)), value = 2.1, isContained = true), + case(ContinuousRange(open(2.0), open(4.0)), value = 3.0, isContained = true), + case(ContinuousRange(open(2.0), open(4.0)), value = 3.9, isContained = true), + case(ContinuousRange(open(2.0), open(4.0)), value = 4.0, isContained = false), + case(ContinuousRange(open(2.0), open(4.0)), value = 5.0, isContained = false), + ).map { (range, value, isContained) -> "[contains] $value in $range should be $isContained" { assertEquals(isContained, value in range) } } + + // @ParameterizedTest(name = "[intersect] intersection of {0} and {1} should be {2}") + // @MethodSource("intersectCases") + fun testIntersect(a: ContinuousRange, b: ContinuousRange, expect: ContinuousRange?) { + assertEquals(expect, a.intersect(b)) + assertEquals(expect, b.intersect(a)) + } + + @TestFactory + fun testIntersect() = listOf( + // //// Comparing ranges with same limit types and different numbers ////// + // Degenerate case + listOf((closed(0.0) to closed(0.0)).let { intersectCase(it, it, it) }), + // Identical cases + generateRanges(0.0, 1.0).map { intersectCase(it, it, it) }, + // Subsume cases + generateRanges(1.0, 3.0).zip(generateRanges(0.0, 4.0)) { a, b -> intersectCase(a, b, a) }, + generateRanges(1.0, 2.0).zip(generateRanges(1.0, 8.0)) { + a, b -> + intersectCase(a, b, b.first to a.second) + }, + // Non-subsume overlap cases + generateRanges(5.0, 7.0).zip(generateRanges(6.0, 8.0)) { + a, b -> + intersectCase(a, b, b.first to a.second) + }, + // //// Comparing ranges with same number and different limit types ////// + generateRanges(1.0, 2.0).let { + it.cartesianProduct(it).map { + (a, b) -> + intersectCase(a, b, narrowest(a.first, b.first) to narrowest(a.second, b.second)) + } + }, + ).flatten().map { (a, b, expected) -> + "[intersect] intersection of $a and $b should be $expected" { + assertEquals(expected, a.intersect(b)) + assertEquals(expected, b.intersect(a)) + } + } + + companion object { + private operator fun String.invoke(block: () -> Unit) = DynamicTest.dynamicTest(this, block) + + fun closed(d: Double) = d.let { ContinuousRange.Limit.Closed(d) } + fun open(d: Double) = d.let { ContinuousRange.Limit.Open(d) } + fun unbounded() = ContinuousRange.Limit.Unbounded() + + /** case for `testContains` */ + private fun case(range: ContinuousRange, value: Double, isContained: Boolean) = Triple(range, value, isContained) + + /** creates test case args for intersect tests */ + private fun intersectCase( + a: Pair, ContinuousRange.Limit>, + b: Pair, ContinuousRange.Limit>, + expect: Pair, ContinuousRange.Limit>? + ) = Triple(ContinuousRange(a.first, a.second), ContinuousRange(b.first, b.second), expect?.let { ContinuousRange(it.first, it.second) }) + + /** Generates ranges with every possible combination of limit types */ + private fun generateRanges(a: Double, b: Double): List, ContinuousRange.Limit>> = listOf( + closed(a) to closed(b), // closed + open(a) to open(b), // open + closed(a) to open(b), // open_UPPER + open(a) to closed(b), // open_LOWER + unbounded() to open(b), // HALF_BOUND_open_UPPER + unbounded() to closed(b), // HALF_BOUND_closed_UPPER + open(a) to unbounded(), // HALF_BOUND_open_LOWER + closed(a) to unbounded(), // HALF_BOUND_closed_LOWER + ) + + /** Cartesian product of two collections */ + private fun Collection.cartesianProduct(other: Collection) = flatMap { a -> other.map { b -> a to b } } + + /** determines the narrowest limit based on type, and ignoring the actual number */ + private fun > narrowest(a: ContinuousRange.Limit, b: ContinuousRange.Limit): ContinuousRange.Limit = when { + a is ContinuousRange.Limit.Open -> a + b is ContinuousRange.Limit.Open -> b + a is ContinuousRange.Limit.Closed -> a + b is ContinuousRange.Limit.Closed -> b + else -> a + } + } +} diff --git a/ion-schema/src/test/kotlin/com/amazon/ionschema/model/DiscreteRangeTest.kt b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/DiscreteRangeTest.kt new file mode 100644 index 00000000..918426e6 --- /dev/null +++ b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/DiscreteRangeTest.kt @@ -0,0 +1,97 @@ +package com.amazon.ionschema.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DiscreteRangeTest { + + @Test fun `degenerate interval should be valid`() { DiscreteIntRange(0, 0) } + @Test fun `proper, bounded interval should be valid`() { DiscreteIntRange(0, 9) } + @Test fun `upper-bounded interval should be valid`() { DiscreteIntRange(0, null) } + @Test fun `lower-bounded interval should be valid`() { DiscreteIntRange(null, 0) } + @Test fun `both limits unbounded should throw IllegalArgumentException`() { assertThrows { DiscreteIntRange(null, null) } } + @Test fun `empty interval should throw IllegalArgumentException`() { assertThrows { DiscreteIntRange(1, 0) } } + + fun testContainsArgs() = listOf( + case(DiscreteIntRange(2, 4), value = 1, isContained = false), + case(DiscreteIntRange(2, 4), value = 2, isContained = true), + case(DiscreteIntRange(2, 4), value = 3, isContained = true), + case(DiscreteIntRange(2, 4), value = 4, isContained = true), + case(DiscreteIntRange(2, 4), value = 5, isContained = false), + case(DiscreteIntRange(null, 4), value = -99, isContained = true), + case(DiscreteIntRange(null, 4), value = -1, isContained = true), + case(DiscreteIntRange(null, 4), value = 0, isContained = true), + case(DiscreteIntRange(null, 4), value = 4, isContained = true), + case(DiscreteIntRange(null, 4), value = 5, isContained = false), + case(DiscreteIntRange(-3, null), value = -99, isContained = false), + case(DiscreteIntRange(-3, null), value = -3, isContained = true), + case(DiscreteIntRange(-3, null), value = 0, isContained = true), + case(DiscreteIntRange(-3, null), value = 4, isContained = true), + case(DiscreteIntRange(-3, null), value = 99, isContained = true), + case(DiscreteIntRange(8, 8), value = 7, isContained = false), + case(DiscreteIntRange(8, 8), value = 8, isContained = true), + case(DiscreteIntRange(8, 8), value = 9, isContained = false), + ) + @MethodSource("testContainsArgs") + @ParameterizedTest(name = "contains check of {1} in {0} should be {2}") + fun testContains(range: DiscreteIntRange, value: Int, isContained: Boolean) { + assertEquals(value in range, isContained) + } + + fun testIntersectArgs() = listOf( + // identical ranges + case(a = DiscreteIntRange(0, 0), b = DiscreteIntRange(0, 0), expected = DiscreteIntRange(0, 0)), + case(a = DiscreteIntRange(1, 2), b = DiscreteIntRange(1, 2), expected = DiscreteIntRange(1, 2)), + case(a = DiscreteIntRange(null, 3), b = DiscreteIntRange(null, 3), expected = DiscreteIntRange(null, 3)), + case(a = DiscreteIntRange(4, null), b = DiscreteIntRange(4, null), expected = DiscreteIntRange(4, null)), + // one range completely subsumes the other + case(a = DiscreteIntRange(0, 1), b = DiscreteIntRange(0, 2), expected = DiscreteIntRange(0, 1)), + case(a = DiscreteIntRange(1, 2), b = DiscreteIntRange(null, 2), expected = DiscreteIntRange(1, 2)), + case(a = DiscreteIntRange(2, 3), b = DiscreteIntRange(2, null), expected = DiscreteIntRange(2, 3)), + // overlap, but neither subsumes the other + case(a = DiscreteIntRange(0, null), b = DiscreteIntRange(null, 0), expected = DiscreteIntRange(0, 0)), + case(a = DiscreteIntRange(0, 4), b = DiscreteIntRange(1, 5), expected = DiscreteIntRange(1, 4)), + case(a = DiscreteIntRange(1, 5), b = DiscreteIntRange(2, null), expected = DiscreteIntRange(2, 5)), + case(a = DiscreteIntRange(2, 6), b = DiscreteIntRange(null, 4), expected = DiscreteIntRange(2, 4)), + case(a = DiscreteIntRange(3, null), b = DiscreteIntRange(null, 5), expected = DiscreteIntRange(3, 5)), + // no overlap + case(a = DiscreteIntRange(0, 0), b = DiscreteIntRange(1, 1), expected = null), + case(a = DiscreteIntRange(0, 0), b = DiscreteIntRange(1, null), expected = null), + case(a = DiscreteIntRange(0, 0), b = DiscreteIntRange(null, -1), expected = null), + ) + @MethodSource("testIntersectArgs") + @ParameterizedTest(name = "intersect of {0} and {1} should be {2}") + fun testIntersect(range1: DiscreteIntRange, range2: DiscreteIntRange, expectedIntersect: DiscreteIntRange?) { + assertEquals(expectedIntersect, range1.intersect(range2)) + assertEquals(expectedIntersect, range2.intersect(range1)) + } + + fun testNegateArgs() = listOf( + case(original = DiscreteIntRange(0, 0), expected = DiscreteIntRange(0, 0)), // degenerate 0 interval + case(original = DiscreteIntRange(1, 1), expected = DiscreteIntRange(-1, -1)), // trivial non-zero interval + case(original = DiscreteIntRange(-2, 2), expected = DiscreteIntRange(-2, 2)), // symmetric (negates to self), proper bounded interval + case(original = DiscreteIntRange(-3, 4), expected = DiscreteIntRange(-4, 3)), // non-symmetric, proper, bounded interval + case(original = DiscreteIntRange(-5, null), expected = DiscreteIntRange(null, 5)), // lower-bounded + case(original = DiscreteIntRange(null, -6), expected = DiscreteIntRange(6, null)), // upper-bounded + ) + @MethodSource("testNegateArgs") + @ParameterizedTest(name = "negate {0} should be {1}") + fun testNegate(original: DiscreteIntRange, expectedNegation: DiscreteIntRange) { + assertEquals(expectedNegation, original.negate()) + } + + companion object { + /** case for `testContains` */ + private fun case(range: DiscreteIntRange, value: Int, isContained: Boolean) = arguments(range, value, isContained) + /** case for `testNegate` */ + private fun case(original: DiscreteIntRange, expected: DiscreteIntRange) = arguments(original, expected) + /** case for `testIntersect` */ + private fun case(a: DiscreteIntRange, b: DiscreteIntRange, expected: DiscreteIntRange?) = arguments(a, b, expected) + } +} diff --git a/ion-schema/src/test/kotlin/com/amazon/ionschema/model/Ieee754InterchangeFormatTest.kt b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/Ieee754InterchangeFormatTest.kt new file mode 100644 index 00000000..ca64627b --- /dev/null +++ b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/Ieee754InterchangeFormatTest.kt @@ -0,0 +1,45 @@ +package com.amazon.ionschema.model + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +class Ieee754InterchangeFormatTest { + + @ParameterizedTest(name = "symbolText for {0}") + @EnumSource + fun `test symbolText`(iif: Ieee754InterchangeFormat) { + val expected = when (iif) { + Ieee754InterchangeFormat.Binary16 -> "binary16" + Ieee754InterchangeFormat.Binary32 -> "binary32" + Ieee754InterchangeFormat.Binary64 -> "binary64" + } + Assertions.assertEquals(expected, iif.symbolText) + } + + @ParameterizedTest(name = "fromSymbolTextOrNull for {0}") + @EnumSource + fun `test fromSymbolTextOrNull`(iif: Ieee754InterchangeFormat) { + val symbolText = iif.symbolText + Assertions.assertEquals(iif, Ieee754InterchangeFormat.fromSymbolTextOrNull(symbolText)) + } + + @Test + fun `fromSymbolTextOrNull should return null when not a valid timestamp precision value`() { + Assertions.assertNull(Ieee754InterchangeFormat.fromSymbolTextOrNull("unary42")) + } + + @ParameterizedTest(name = "fromSymbolText for {0}") + @EnumSource + fun `test fromSymbolText`(iif: Ieee754InterchangeFormat) { + val symbolText = iif.symbolText + Assertions.assertEquals(iif, Ieee754InterchangeFormat.fromSymbolText(symbolText)) + } + + @Test + fun `fromSymbolTextOrNull should throw exception when not a valid timestamp precision value`() { + assertThrows { Ieee754InterchangeFormat.fromSymbolText("unary42") } + } +} diff --git a/ion-schema/src/test/kotlin/com/amazon/ionschema/model/SchemaDocumentTest.kt b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/SchemaDocumentTest.kt new file mode 100644 index 00000000..f8e0b474 --- /dev/null +++ b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/SchemaDocumentTest.kt @@ -0,0 +1,147 @@ +package com.amazon.ionschema.model + +import com.amazon.ion.system.IonSystemBuilder +import com.amazon.ionschema.IonSchemaVersion +import com.amazon.ionschema.model.SchemaDocument.Item +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertIterableEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +@ExperimentalIonSchemaModel +class SchemaDocumentTest { + + val ION = IonSystemBuilder.standard().build() + + @Test + fun `test get header when no header present`() { + val schema = SchemaDocument("schema.isl", listOf()) + assertNull(schema.header) + } + + @Test + fun `test get header when header present`() { + val header = Item.Header() + val footer = Item.Footer() + val schema = SchemaDocument("schema.isl", listOf(header, footer)) + assertSame(header, schema.header) + } + + @Test + fun `test get footer when no footer present`() { + val schema = SchemaDocument("schema.isl", listOf()) + assertNull(schema.footer) + } + + @Test + fun `test get footer when footer present`() { + val header = Item.Header() + val footer = Item.Footer() + val schema = SchemaDocument("schema.isl", listOf(header, footer)) + assertSame(footer, schema.footer) + } + + @Test + fun `test get declaredTypes when no types present`() { + val schema = SchemaDocument("schema.isl", listOf()) + assertTrue(schema.declaredTypes.isEmpty()) + } + + @Test + fun `test get declaredTypes when some types present`() { + val type0 = NamedTypeDefinition( + "type0", + TypeDefinition( + constraints = listOf(), + ) + ) + val type1 = NamedTypeDefinition( + "type1", + TypeDefinition( + constraints = listOf(), + ) + ) + val schema = SchemaDocument("schema.isl", listOf(Item.Type(type0), Item.Type(type1))) + assertEquals(2, schema.declaredTypes.size) + assertSame(type0, schema.declaredTypes[0]) + assertSame(type1, schema.declaredTypes[1]) + } + + @Test + fun `test get ion schema version when version marker is present`() { + val schema = SchemaDocument( + "schema.isl", + listOf( + Item.OpenContent(ION.newNull()), + Item.VersionMarker(IonSchemaVersion.v2_0) + ) + ) + assertEquals(IonSchemaVersion.v2_0, schema.ionSchemaVersion) + } + + @Test + fun `test get ion schema version when header is before any version marker is present`() { + val schema = SchemaDocument( + "schema.isl", + listOf( + Item.Header(), + Item.VersionMarker(IonSchemaVersion.v2_0), + Item.Footer(), + ) + ) + // Should be ISL 1.0 since header was before version marker + assertEquals(IonSchemaVersion.v1_0, schema.ionSchemaVersion) + } + + @Test + fun `test get ion schema version when type is before any version marker is present`() { + val type0 = NamedTypeDefinition( + "type0", + TypeDefinition( + constraints = listOf(), + ) + ) + val schema = SchemaDocument( + "schema.isl", + listOf( + Item.Type(type0), + Item.VersionMarker(IonSchemaVersion.v2_0), + ) + ) + // Should be ISL 1.0 since type was before version marker + assertEquals(IonSchemaVersion.v1_0, schema.ionSchemaVersion) + } + + @Test + fun `it should be possible to retrieve open content in the original order`() { + val type0 = NamedTypeDefinition( + "type0", + TypeDefinition( + constraints = listOf(), + ) + ) + val type1 = NamedTypeDefinition( + "type1", + TypeDefinition( + constraints = listOf(), + ) + ) + val schemaItems = listOf( + Item.OpenContent(ION.newInt(1)), + Item.VersionMarker(IonSchemaVersion.v2_0), + Item.OpenContent(ION.newInt(2)), + Item.Header(), + Item.OpenContent(ION.newInt(3)), + Item.Type(type0), + Item.OpenContent(ION.newInt(4)), + Item.Type(type1), + Item.OpenContent(ION.newInt(5)), + Item.Footer(), + Item.OpenContent(ION.newInt(6)), + ) + val schema = SchemaDocument("schema.isl", schemaItems) + assertIterableEquals(schemaItems, schema.items) + } +} diff --git a/ion-schema/src/test/kotlin/com/amazon/ionschema/model/TimestampOffsetValueTest.kt b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/TimestampOffsetValueTest.kt new file mode 100644 index 00000000..d58585ef --- /dev/null +++ b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/TimestampOffsetValueTest.kt @@ -0,0 +1,68 @@ +package com.amazon.ionschema.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TimestampOffsetValueTest { + + fun validOffsets(): Iterable = listOf( + // 1. int minutes or null (unknown offset) 2. string + arguments(null, "-00:00"), + arguments(0, "+00:00"), + arguments(1, "+00:01"), + arguments(59, "+00:59"), + arguments(60, "+01:00"), + arguments(1439, "+23:59"), + arguments(-1, "-00:01"), + arguments(-59, "-00:59"), + arguments(-60, "-01:00"), + arguments(-1439, "-23:59"), + ) + + @ParameterizedTest(name = "parse(\"{1}\") should be an offset of {0} minutes") + @MethodSource("validOffsets") + fun `parse should handle valid timestamp offset strings`(expectedMinutes: Int?, stringValue: String) { + val expectedOffset = TimestampOffsetValue.fromMinutes(expectedMinutes) + assertEquals(expectedOffset, TimestampOffsetValue.parse(stringValue)) + } + + @ParameterizedTest(name = "TimestampOffsetValue.fromMinutes({0}).toString() should be \"{1}\"") + @MethodSource("validOffsets") + fun `toString() should produce the correct timestamp offset string`(minutes: Int?, expected: String) { + val offset = TimestampOffsetValue.fromMinutes(minutes) + assertEquals(expected, offset.toString()) + } + + @ParameterizedTest(name = "constructor should throw exception for {0} minutes") + @ValueSource(ints = [1440, -1440]) + fun `constructor should throw exception for invalid number of minutes`(minutes: Int) { + assertThrows { TimestampOffsetValue.Known(minutes) } + } + + @ParameterizedTest(name = "parse(\"{0}\") should throw exception") + @ValueSource( + strings = [ + "00:00", // no sign + "*00:00", // sign is not + or - + "+1:00", // missing zero-padding on hours + "+01:1", // missing zero-padding on minutes + "+001:00", // extra zero-padding on hours + "+01:001", // extra zero-padding on minutes + "+00:60", // minutes too high + "-00:60", // minutes too low + "+24:00", // hours too high + "-24:00", // hours too low + "+0000", // No ':' separator + ] + ) + fun `parse() should throw exception for invalid offset string`(string: String) { + assertThrows { TimestampOffsetValue.parse(string) } + } +} diff --git a/ion-schema/src/test/kotlin/com/amazon/ionschema/model/TimestampPrecisionValueTest.kt b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/TimestampPrecisionValueTest.kt new file mode 100644 index 00000000..50dbceaa --- /dev/null +++ b/ion-schema/src/test/kotlin/com/amazon/ionschema/model/TimestampPrecisionValueTest.kt @@ -0,0 +1,51 @@ +package com.amazon.ionschema.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +class TimestampPrecisionValueTest { + + @ParameterizedTest(name = "symbolText for {0}") + @EnumSource + fun `test symbolText`(tpv: TimestampPrecisionValue) { + val expected = when (tpv) { + TimestampPrecisionValue.Year -> "year" + TimestampPrecisionValue.Month -> "month" + TimestampPrecisionValue.Day -> "day" + TimestampPrecisionValue.Minute -> "minute" + TimestampPrecisionValue.Second -> "second" + TimestampPrecisionValue.Millisecond -> "millisecond" + TimestampPrecisionValue.Microsecond -> "microsecond" + TimestampPrecisionValue.Nanosecond -> "nanosecond" + } + assertEquals(expected, tpv.symbolText) + } + + @ParameterizedTest(name = "fromSymbolTextOrNull for {0}") + @EnumSource + fun `test fromSymbolTextOrNull`(tpv: TimestampPrecisionValue) { + val symbolText = tpv.symbolText + assertEquals(tpv, TimestampPrecisionValue.fromSymbolTextOrNull(symbolText)) + } + + @Test + fun `fromSymbolTextOrNull should return null when not a valid timestamp precision value`() { + assertNull(TimestampPrecisionValue.fromSymbolTextOrNull("stardate")) + } + + @ParameterizedTest(name = "fromSymbolText for {0}") + @EnumSource + fun `test fromSymbolText`(tpv: TimestampPrecisionValue) { + val symbolText = tpv.symbolText + assertEquals(tpv, TimestampPrecisionValue.fromSymbolText(symbolText)) + } + + @Test + fun `fromSymbolTextOrNull should throw exception when not a valid timestamp precision value`() { + assertThrows { TimestampPrecisionValue.fromSymbolText("stardate") } + } +}