From b55e3804b4925e08869ee2d7f31ea1c228c58eb7 Mon Sep 17 00:00:00 2001 From: Matthew Pope Date: Wed, 2 Nov 2022 14:04:24 -0700 Subject: [PATCH] Adds Ion Schema 2.0 support for imports --- ion-schema-tests | 2 +- .../ionschema/internal/SchemaImpl_2_0.kt | 124 +++++----- .../ionschema/internal/TypeReference.kt | 18 +- .../ionschema/internal/util/IonSchema_2_0.kt | 5 + .../ionschema/internal/util/Preconditions.kt | 134 +++++++++- .../amazon/ionschema/IonSchemaTestsRunner.kt | 5 +- .../internal/util/PreconditionsTest.kt | 232 +++++++++++++++++- 7 files changed, 442 insertions(+), 78 deletions(-) diff --git a/ion-schema-tests b/ion-schema-tests index 0add43f4..8a32c92b 160000 --- a/ion-schema-tests +++ b/ion-schema-tests @@ -1 +1 @@ -Subproject commit 0add43f40036659f0d6170b95938dec94b8e0059 +Subproject commit 8a32c92bf9d551381cc14e449e161bf87b17ce75 diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/SchemaImpl_2_0.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/SchemaImpl_2_0.kt index 4dc6e6f6..0d374cd0 100644 --- a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/SchemaImpl_2_0.kt +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/SchemaImpl_2_0.kt @@ -29,9 +29,12 @@ import com.amazon.ionschema.Type import com.amazon.ionschema.Violations import com.amazon.ionschema.internal.util.IonSchema_2_0 import com.amazon.ionschema.internal.util.getFields +import com.amazon.ionschema.internal.util.getIslOptionalField +import com.amazon.ionschema.internal.util.getIslRequiredField import com.amazon.ionschema.internal.util.islRequire import com.amazon.ionschema.internal.util.islRequireElementType import com.amazon.ionschema.internal.util.islRequireIonTypeNotNull +import com.amazon.ionschema.internal.util.islRequireOnlyExpectedFieldNames import com.amazon.ionschema.internal.util.islRequireZeroOrOneElements import com.amazon.ionschema.internal.util.markReadOnly import kotlin.contracts.ExperimentalContracts @@ -47,12 +50,12 @@ internal class SchemaImpl_2_0 private constructor( override val schemaId: String?, preloadedImports: Map, preloadedUserReservedFields: UserReservedFields, - /* - * [types] is declared as a MutableMap in order to be populated DURING - * INITIALIZATION ONLY. This enables type B to find its already-loaded - * dependency type A. After initialization, [types] is expected to - * be treated as immutable as required by the Schema interface. - */ + /* + * [types] is declared as a MutableMap in order to be populated DURING + * INITIALIZATION ONLY. This enables type B to find its already-loaded + * dependency type A. After initialization, [types] is expected to + * be treated as immutable as required by the Schema interface. + */ private val types: MutableMap, ) : SchemaImpl { @@ -131,9 +134,7 @@ internal class SchemaImpl_2_0 private constructor( } isType(it) -> { islRequire(!foundFooter) { "Types may not occur after the schema footer." } - islRequire(it.count { it.fieldName == "name" } == 1) { "Top-level types must have exactly one name." } - val name = islRequireIonTypeNotNull(it["name"]) { "Type names must be a non-null symbol." } - islRequire(name.typeAnnotations.isEmpty()) { "Type names must not have annotations." } + it.getIslRequiredField("name") val newType = TypeImpl(it, this) islRequire(newType.name !in types.keys) { "Invalid duplicate type name: '${newType.name}'" } addType(types, newType) @@ -242,17 +243,11 @@ internal class SchemaImpl_2_0 private constructor( * Gets the list of field names that the user would like to reserve for a particular Ion Schema structure. */ private fun loadUserReservedFieldsSubfield(userContent: IonStruct, fieldName: String): Set { - return userContent.getFields(fieldName) - .islRequireZeroOrOneElements { "'$fieldName' field may only appear 0 or 1 times in the 'user_reserved_fields' struct" } - ?.let { list -> - islRequireIonTypeNotNull(list) { "'$fieldName' field in user_reserved_fields struct must be a non-null Ion list" } - islRequire(list.typeAnnotations.isEmpty()) { "'$fieldName' list in user_reserved_fields struct may not have any annotations" } - islRequireElementType(list) { "'$fieldName' list in user_reserved_fields struct must only contain non-null Ion symbols" } - .onEach { islRequire(it.typeAnnotations.isEmpty()) { "symbols in the user_reserved_fields '$fieldName' list may not have any annotations" } } - .map { it.stringValue() } - .onEach { islRequire(it !in IonSchema_2_0.KEYWORDS) { "Ion Schema 2.0 keyword '$it' may not be declared as a user reserved field: $userContent" } } - .toSet() - } + return userContent.getIslOptionalField(fieldName) + ?.islRequireElementType("list of user reserved symbols for $fieldName") + ?.map { it.stringValue() } + ?.onEach { islRequire(it !in IonSchema_2_0.KEYWORDS) { "Ion Schema 2.0 keyword '$it' may not be declared as a user reserved field: $userContent" } } + ?.toSet() ?: emptySet() } @@ -290,55 +285,62 @@ internal class SchemaImpl_2_0 private constructor( val importsMap = mutableMapOf() val importSet: MutableSet = schemaSystem.getSchemaImportSet() - (header.get("imports") as? IonList) - ?.filterIsInstance() - ?.forEach { - val childImportId = it["id"] as IonText - val alias = it["as"] as? IonSymbol - // if importSet has an import with this id then do not load schema again to break the cycle. - if (!importSet.contains(childImportId.stringValue())) { - var parentImportId = schemaId ?: "" - - // if Schema is importing itself then throw error - if (parentImportId.equals(childImportId.stringValue())) { - throw InvalidSchemaException("Schema can not import itself.") - } + val imports = header.getIslOptionalField("imports") + ?.islRequireElementType(containerDescription = "imports list") + // If there's no imports field, then there's nothing to do + ?: return emptyMap() - // add parent and current schema to importSet and continue loading current schema - importSet.add(parentImportId) - importSet.add(childImportId.stringValue()) - val importedSchema = schemaSystem.loadSchema(childImportId.stringValue()) - importSet.remove(childImportId.stringValue()) - importSet.remove(parentImportId) + imports.forEach { + it.islRequireOnlyExpectedFieldNames(IonSchema_2_0.IMPORT_KEYWORDS) - val schemaAndTypes = importsMap.getOrPut(childImportId.stringValue()) { - SchemaAndTypeImports(childImportId.stringValue(), importedSchema) - } + val idField = it.getIslRequiredField("id") + val typeField = it.getIslOptionalField("type") + val asField = it.getIslOptionalField("as") - val typeName = (it["type"] as? IonSymbol)?.stringValue() - if (typeName != null) { - var importedType = importedSchema.getDeclaredType(typeName) - ?.toImportedType(childImportId.stringValue()) + typeField ?: islRequire(asField == null) { "'as' only allowed when 'type' is present: $it" } - importedType ?: throw InvalidSchemaException("Schema $childImportId doesn't contain a type named '$typeName'") + val importedSchemaId = idField.stringValue() + // if Schema is importing itself then throw error + if (schemaId == importedSchemaId) { + throw InvalidSchemaException("Schema can not import itself: $it") + } + // if importSet has an import with this id then do not load schema again to break the cycle. + if (!importSet.contains(importedSchemaId)) { - if (alias != null) { - importedType = TypeAliased(alias, importedType) - } - addType(typeMap, importedType) - schemaAndTypes.addType(alias?.stringValue() ?: typeName, importedType) - } else { - val typesToAdd = importedSchema.getDeclaredTypes() - - typesToAdd.asSequence() - .map { type -> type.toImportedType(childImportId.stringValue()) } - .forEach { type -> - addType(typeMap, type) - schemaAndTypes.addType(type.name, type) - } + // add current schema to importSet and continue loading current schema + importSet.add(importedSchemaId) + val importedSchema = runCatching { schemaSystem.loadSchema(importedSchemaId) } + .getOrElse { e -> throw InvalidSchemaException("Unable to load schema '$importedSchemaId'; ${e.message}") } + importSet.remove(importedSchemaId) + + val schemaAndTypes = importsMap.getOrPut(importedSchemaId) { + SchemaAndTypeImports(importedSchemaId, importedSchema) + } + + val typeName = typeField?.stringValue() + if (typeName != null) { + var importedType = importedSchema.getDeclaredType(typeName) + ?.toImportedType(importedSchemaId) + + importedType ?: throw InvalidSchemaException("Schema $importedSchemaId doesn't contain a type named '$typeName'") + + if (asField != null) { + importedType = TypeAliased(asField, importedType) } + addType(typeMap, importedType) + schemaAndTypes.addType(asField?.stringValue() ?: typeName, importedType) + } else { + val typesToAdd = importedSchema.getDeclaredTypes() + + typesToAdd.asSequence() + .map { type -> type.toImportedType(importedSchemaId) } + .forEach { type -> + addType(typeMap, type) + schemaAndTypes.addType(type.name, type) + } } } + } return importsMap.mapValues { ImportImpl(it.value.id, it.value.schema, it.value.types) } diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/TypeReference.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/TypeReference.kt index ec15680a..949c7af1 100644 --- a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/TypeReference.kt +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/TypeReference.kt @@ -23,7 +23,11 @@ import com.amazon.ionschema.InvalidSchemaException import com.amazon.ionschema.IonSchemaVersion import com.amazon.ionschema.Schema import com.amazon.ionschema.Violations +import com.amazon.ionschema.internal.util.IonSchema_2_0 +import com.amazon.ionschema.internal.util.getIslOptionalField +import com.amazon.ionschema.internal.util.getIslRequiredField import com.amazon.ionschema.internal.util.islRequire +import com.amazon.ionschema.internal.util.islRequireOnlyExpectedFieldNames import com.amazon.ionschema.internal.util.markReadOnly /** @@ -77,13 +81,19 @@ internal class TypeReference private constructor() { } private fun handleStruct(ion: IonStruct, schema: Schema, isField: Boolean): () -> TypeInternal { - val id = ion["id"] as? IonText + val id = ion.getIslOptionalField("id") val type = when { id != null -> { // import - val newSchema = schema.getSchemaSystem().loadSchema(id.stringValue()) - val typeName = ion.get("type") as IonSymbol - newSchema.getType(typeName.stringValue()) as? TypeInternal + if (schema.ionSchemaLanguageVersion >= IonSchemaVersion.v2_0) { + ion.islRequireOnlyExpectedFieldNames(IonSchema_2_0.INLINE_IMPORT_KEYWORDS) + val thisSchemaId = (schema as? SchemaImpl)?.schemaId + islRequire(id.stringValue() != thisSchemaId) { "A schema may not directly import itself: $ion" } + } + val typeName = ion.getIslRequiredField("type") + val importedSchema = runCatching { schema.getSchemaSystem().loadSchema(id.stringValue()) } + .getOrElse { e -> throw InvalidSchemaException("Unable to load schema '${id.stringValue()}'; ${e.message}") } + importedSchema.getType(typeName.stringValue()) as? TypeInternal } isField -> TypeImpl(ion, schema) ion.size() == 1 && ion["type"] != null -> { diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/IonSchema_2_0.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/IonSchema_2_0.kt index e54296e8..2ab1ea02 100644 --- a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/IonSchema_2_0.kt +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/IonSchema_2_0.kt @@ -24,6 +24,11 @@ internal object IonSchema_2_0 { */ val IMPORT_KEYWORDS = setOf("id", "type", "as") + /** + * Keywords that are valid in an inline import. + */ + val INLINE_IMPORT_KEYWORDS = setOf("id", "type") + /** * Keywords that are valid as annotations on top-level types. */ diff --git a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/Preconditions.kt b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/Preconditions.kt index aaf731f5..be00d941 100644 --- a/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/Preconditions.kt +++ b/ion-schema/src/main/kotlin/com/amazon/ionschema/internal/util/Preconditions.kt @@ -15,7 +15,7 @@ package com.amazon.ionschema.internal.util -import com.amazon.ion.IonContainer +import com.amazon.ion.IonStruct import com.amazon.ion.IonValue import com.amazon.ionschema.InvalidSchemaException import kotlin.contracts.ExperimentalContracts @@ -83,12 +83,132 @@ internal inline fun Collection.islRequireZeroOrOneElements(lazyMess } /** - * Validates that the given [IonContainer] has homogeneous elements of Ion type [T]. + * Validates that all elements of an [Iterable] are of Ion type [T]. + * If [allowAnnotations] is false, validates that all elements have no annotations. + * If [allowIonNulls] is false, validates that all elements are not an Ion null value. * - * @throws InvalidSchemaException if this [IonContainer] does not meet the above condition. - * @return the elements of [container] as `List` + * @throws InvalidSchemaException if any element in the collection does not meet the above conditions. + * @return the elements of [this] as `Iterable` */ -internal inline fun islRequireElementType(container: IonContainer, allowNulls: Boolean = false, lazyMessage: () -> Any): List { - islRequire(container.all { it is T && (allowNulls || !it.isNullValue) }, lazyMessage) - return container.filterIsInstance() +internal inline fun Iterable.islRequireElementType( + containerDescription: String, + allowIonNulls: Boolean = false, + allowAnnotations: Boolean = false, +): Iterable { + this.onEach { + if (allowIonNulls) { + islRequire(it is T) { "$containerDescription elements must be a ${ionTypeDescription()}: $this" } + } else { + islRequireIonTypeNotNull(it) { "$containerDescription elements must be a non-null ${ionTypeDescription()}: $this" } + } + if (!allowAnnotations) { + islRequire(it.typeAnnotations.isEmpty()) { "$containerDescription elements may not have annotations: $this" } + } + } + @Suppress("UNCHECKED_CAST") + return this as Iterable +} + +/** + * Validates that all elements of a [List] are of Ion type [T]. + * If [allowAnnotations] is false, validates that all elements have no annotations. + * If [allowIonNulls] is false, validates that all elements are not an Ion null value. + * + * @throws InvalidSchemaException if any element in the collection does not meet the above conditions. + * @return the elements of [this] as `List` + */ +internal inline fun List.islRequireElementType( + containerDescription: String, + allowIonNulls: Boolean = false, + allowAnnotations: Boolean = false, +): List = (this as Iterable).islRequireElementType( + containerDescription, + allowIonNulls, + allowAnnotations, +) as List + +/** + * Gets a required field from an IonStruct, validating for type, nullability, and presence of annotations. + * + * Validates that a given field name is present in the struct exactly once, and that the corresponding field value is + * of type [T]. + * If [allowAnnotations] is false, validates that the field value has no annotations. + * If [allowIonNulls] is false, validates that the field value is not an Ion null value. + * + * @throws InvalidSchemaException if this [IonStruct] does not contain exactly one element with the given field name + * or if the element does not meet the required conditions. + * @return the value for the given field name + */ +internal inline fun IonStruct.getIslRequiredField( + fieldName: String, + allowIonNulls: Boolean = false, + allowAnnotations: Boolean = false, +): T { + val theValue = getIslOptionalField(fieldName, allowIonNulls, allowAnnotations) + return islRequireNotNull(theValue) { "missing required field '$fieldName': $this" } +} + +/** + * Gets an optional field from an IonStruct, validating for type, nullability, and presence of annotations. + * + * Validates that a given field name is present in the struct zero or one times, and that the corresponding field value + * (if it exists) is of type [T]. + * If [allowAnnotations] is false, validates that the field value has no annotations. + * If [allowIonNulls] is false, validates that the field value is not an Ion null value. + * + * If the field name does not occur in the struct, returns (Kotlin) `null`. + * + * @throws InvalidSchemaException if this [IonStruct] does not contain zero or one element with the given field name + * or if the element does not meet the required conditions. + * @return the value for the given field name or null if no value has the given field name + */ +internal inline fun IonStruct.getIslOptionalField( + fieldName: String, + allowIonNulls: Boolean = false, + allowAnnotations: Boolean = false, +): T? { + if (!this.containsKey(fieldName)) return null + val theValue = this.getFields(fieldName) + .also { islRequire(it.size == 1) { "field '$fieldName' may not occur more than once: $this" } } + .single() + if (allowIonNulls) { + islRequire(theValue is T) { "field must be a ${ionTypeDescription()}; '$fieldName': $theValue" } + } else { + islRequireIonTypeNotNull(theValue) { "field must be a non-null ${ionTypeDescription()}; '$fieldName': $theValue" } + } + if (!allowAnnotations) { + islRequire(theValue.typeAnnotations.isEmpty()) { "field may not have annotations; '$fieldName': $theValue" } + } + return theValue +} + +/** + * Converts an interface in the [IonValue] hierarchy into a prose-friendly name. + */ +private inline fun ionTypeDescription(): String { + return when (val name = T::class.simpleName?.removePrefix("Ion")?.toLowerCase()) { + null -> TODO("Unreachable") + "number" -> "int, float, or decimal" + "text" -> "string or symbol" + "sequence" -> "list or sexp" + "container" -> "struct, list, or sexp" + "lob" -> "blob or clob" + else -> name + } +} + +/** + * Validates that an [IonStruct] contains no unexpected fields. + * @return [this] + * @throws InvalidSchemaException if any fields have a name not in [expectedFieldNames]. + */ +internal fun IonStruct.islRequireOnlyExpectedFieldNames( + expectedFieldNames: Collection, +): IonStruct { + val unknownFields = this.filterNot { it.fieldName in expectedFieldNames } + if (unknownFields.isNotEmpty()) { + throw InvalidSchemaException("Unknown fields ${unknownFields.map { it.fieldName }} in struct: $this") + } else { + return this + } } diff --git a/ion-schema/src/test/kotlin/com/amazon/ionschema/IonSchemaTestsRunner.kt b/ion-schema/src/test/kotlin/com/amazon/ionschema/IonSchemaTestsRunner.kt index 58224500..62d7859c 100644 --- a/ion-schema/src/test/kotlin/com/amazon/ionschema/IonSchemaTestsRunner.kt +++ b/ion-schema/src/test/kotlin/com/amazon/ionschema/IonSchemaTestsRunner.kt @@ -36,9 +36,8 @@ class IonSchemaTests_1_0 : TestFactory by IonSchemaTestsRunner(v1_0) class IonSchemaTests_2_0 : TestFactory by IonSchemaTestsRunner( islVersion = v2_0, additionalFileFilter = { - it.path.contains("ion_schema_2_0/schema/") || - it.path.contains("ion_schema_2_0/constraints/") || - it.path.contains("ion_schema_2_0/open_content/") + // Pending fix for https://github.com/amzn/ion-schema-kotlin/issues/209 + !it.path.contains("cycles/") } ) diff --git a/ion-schema/src/test/kotlin/com/amazon/ionschema/internal/util/PreconditionsTest.kt b/ion-schema/src/test/kotlin/com/amazon/ionschema/internal/util/PreconditionsTest.kt index 7d7ad0e9..e5cedca6 100644 --- a/ion-schema/src/test/kotlin/com/amazon/ionschema/internal/util/PreconditionsTest.kt +++ b/ion-schema/src/test/kotlin/com/amazon/ionschema/internal/util/PreconditionsTest.kt @@ -1,14 +1,20 @@ package com.amazon.ionschema.internal.util +import com.amazon.ion.IonContainer import com.amazon.ion.IonInt +import com.amazon.ion.IonList +import com.amazon.ion.IonString +import com.amazon.ion.IonStruct +import com.amazon.ion.IonSymbol import com.amazon.ion.IonValue import com.amazon.ion.system.IonSystemBuilder import com.amazon.ionschema.InvalidSchemaException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows -import kotlin.test.assertEquals -import kotlin.test.assertSame class PreconditionsTest { @@ -156,4 +162,226 @@ class PreconditionsTest { val ionInt: IonInt = maybeIonInt } } + + @Nested + inner class islRequireElementType { + @Test + fun `when collection is empty, should return empty list`() { + expectOk("[]") { + it.islRequireElementType("a list") + } + } + + @Test + fun `when collection has null value and nulls not allowed, should throw`() { + assertThrows { + (ion.singleValue(" [a, null.symbol, b] ") as IonList) + .islRequireElementType("my cool list") + }.also { + assertTrue("my cool list" in it.message!!) + } + } + + @Test + fun `when collection has null value and nulls are allowed, should return normally`() { + expectOk(" [a, null.symbol, b] ") { + it.islRequireElementType("my cool list", allowIonNulls = true) + } + } + + @Test + fun `when collection has annotated value and annotations not allowed, should throw`() { + assertThrows { + (ion.singleValue(" [a, foo::b, c] ") as IonList) + .islRequireElementType("my cool list") + }.also { + assertTrue("my cool list" in it.message!!) + } + } + + @Test + fun `when collection has annotated value and annotations are allowed, should return normally`() { + expectOk(" [a, foo::b, c] ") { + it.islRequireElementType("my cool list", allowAnnotations = true) + } + } + + private fun expectOk(ionText: String, fn: (Iterable) -> Iterable) { + val theList = (ion.singleValue(ionText) as IonContainer) + val result = fn(theList) + assertSame(theList, result) + } + } + + @Nested + inner class getIslRequiredField { + @Test + fun `when field is right type, should return`() { + val result = (ion.singleValue(" {a:1} ") as IonStruct) + .getIslRequiredField("a") + + assertEquals(1, result.intValue()) + } + + @Test + fun `when field is wrong type, should throw`() { + assertThrows { + (ion.singleValue(" {a:1.23} ") as IonStruct) + .getIslRequiredField("a") + } + } + + @Test + fun `when field is not present, should throw`() { + assertThrows { + (ion.singleValue(" {a:1} ") as IonStruct) + .getIslRequiredField("b") + } + } + + @Test + fun `when field is repeated, should throw`() { + assertThrows { + (ion.singleValue(" {a:1, a:1} ") as IonStruct) + .getIslRequiredField("a") + } + } + + @Test + fun `when field is a null value and nulls not allowed, should throw`() { + assertThrows { + (ion.singleValue(" {a:null.int} ") as IonStruct) + .getIslRequiredField("a") + } + } + + @Test + fun `when field is a null value and nulls are allowed, should return ion null`() { + val result = (ion.singleValue(" {a:null.int} ") as IonStruct) + .getIslRequiredField("a", allowIonNulls = true) + + assertTrue(result.isNullValue) + } + + @Test + fun `when field has annotations and annotations not allowed, should throw`() { + assertThrows { + (ion.singleValue(" {a:foo::1} ") as IonStruct) + .getIslRequiredField("a") + } + } + + @Test + fun `when field has annotations and annotations are allowed, should return`() { + val result = (ion.singleValue(" {a:foo::1} ") as IonStruct) + .getIslRequiredField("a", allowAnnotations = true) + + assertTrue(result.hasTypeAnnotation("foo")) + assertEquals(1, result.intValue()) + } + } + + @Nested + inner class getIslOptionalField { + @Test + fun `when field is right type, should return`() { + val result = (ion.singleValue(" {a:1} ") as IonStruct) + .getIslOptionalField("a") + + result!! // Asserts that result is not null + assertEquals(1, result.intValue()) + } + + @Test + fun `when field is wrong type, should throw`() { + assertThrows { + (ion.singleValue(" {a:1.23} ") as IonStruct) + .getIslOptionalField("a") + } + } + + @Test + fun `when field is not present, should return null`() { + val result = (ion.singleValue(" {a:1} ") as IonStruct) + .getIslOptionalField("b") + + assertEquals(null, result) + } + + @Test + fun `when field is repeated, should throw`() { + assertThrows { + (ion.singleValue(" {a:1, a:1} ") as IonStruct) + .getIslOptionalField("a") + } + } + + @Test + fun `when field is a null value and nulls not allowed, should throw`() { + assertThrows { + (ion.singleValue(" {a:null.int} ") as IonStruct) + .getIslOptionalField("a") + } + } + + @Test + fun `when field is a null value and nulls are allowed, should return ion null`() { + val result = (ion.singleValue(" {a:null.int} ") as IonStruct) + .getIslOptionalField("a", allowIonNulls = true) + + result!! // Asserts that result is not null + assertTrue(result.isNullValue) + } + + @Test + fun `when field has annotations and annotations not allowed, should throw`() { + assertThrows { + (ion.singleValue(" {a:foo::1} ") as IonStruct) + .getIslOptionalField("a") + } + } + + @Test + fun `when field has annotations and annotations are allowed, should return`() { + val result = (ion.singleValue(" {a:foo::1} ") as IonStruct) + .getIslOptionalField("a", allowAnnotations = true) + + result!! // Asserts that result is not null + assertTrue(result.hasTypeAnnotation("foo")) + assertEquals(1, result.intValue()) + } + } + + @Nested + inner class islRequireOnlyExpectedFieldNames { + + @Test + fun `when no fields, should return normally`() { + val struct = (ion.singleValue("{}") as IonStruct) + val result = struct.islRequireOnlyExpectedFieldNames(listOf("a")) + assertSame(struct, result) + } + + @Test + fun `when only expected fields, should return normally`() { + val struct = (ion.singleValue("{a:1}") as IonStruct) + val result = struct.islRequireOnlyExpectedFieldNames(listOf("a")) + assertSame(struct, result) + } + + @Test + fun `when expected field is repeated, should return normally`() { + val struct = (ion.singleValue("{a:1, a:2}") as IonStruct) + val result = struct.islRequireOnlyExpectedFieldNames(listOf("a")) + assertSame(struct, result) + } + + @Test + fun `when unexpected field is present, should throw`() { + val struct = (ion.singleValue("{a:1, b:2}") as IonStruct) + assertThrows { + struct.islRequireOnlyExpectedFieldNames(listOf("a")) + } + } + } }