Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds readers for schema headers and footers #271

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

Filter by extension

Filter by extension

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

import com.amazon.ion.IonStruct
import com.amazon.ion.IonValue
import com.amazon.ionschema.internal.util.islRequire
import com.amazon.ionschema.internal.util.islRequireExactAnnotations
import com.amazon.ionschema.internal.util.islRequireIonTypeNotNull
import com.amazon.ionschema.model.ExperimentalIonSchemaModel
import com.amazon.ionschema.model.SchemaDocument
import com.amazon.ionschema.util.toBag

@ExperimentalIonSchemaModel
internal class FooterReader(private val isValidOpenContentField: ReaderContext.(String) -> Boolean) {

fun readFooter(context: ReaderContext, footerValue: IonValue): SchemaDocument.Item.Footer {
islRequireIonTypeNotNull<IonStruct>(footerValue) { "schema_footer must be a non-null struct; was: $footerValue" }
islRequireExactAnnotations(footerValue, "schema_footer") { "schema_footer may not have extra annotations" }

val unexpectedFieldNames = footerValue.map { it.fieldName }.filterNot { context.isValidOpenContentField(it) }

islRequire(unexpectedFieldNames.isEmpty()) { "Found illegal field names $unexpectedFieldNames in schema footer: $footerValue" }

val openContent = footerValue.map { it.fieldName to it }.toBag()
return SchemaDocument.Item.Footer(openContent)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.amazon.ionschema.reader.internal

import com.amazon.ion.IonList
import com.amazon.ion.IonStruct
import com.amazon.ion.IonSymbol
import com.amazon.ion.IonText
import com.amazon.ion.IonValue
import com.amazon.ionschema.IonSchemaVersion
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.islRequireExactAnnotations
import com.amazon.ionschema.internal.util.islRequireIonTypeNotNull
import com.amazon.ionschema.internal.util.islRequireNoIllegalAnnotations
import com.amazon.ionschema.internal.util.islRequireOnlyExpectedFieldNames
import com.amazon.ionschema.internal.util.islRequireZeroOrOneElements
import com.amazon.ionschema.model.ExperimentalIonSchemaModel
import com.amazon.ionschema.model.HeaderImport
import com.amazon.ionschema.model.SchemaDocument
import com.amazon.ionschema.model.UserReservedFields
import com.amazon.ionschema.util.toBag

@ExperimentalIonSchemaModel
internal class HeaderReader(private val ionSchemaVersion: IonSchemaVersion) {

/**
* Reads the header
*/
fun readHeader(context: ReaderContext, headerValue: IonValue): SchemaDocument.Item.Header {
islRequire(!context.foundHeader) { "Only one schema header is allowed in a schema document." }
islRequire(!context.foundAnyType) { "Schema header must appear before any types." }
context.foundHeader = true

islRequireIonTypeNotNull<IonStruct>(headerValue) { "schema_header must be a non-null struct; was: $headerValue" }
islRequireExactAnnotations(headerValue, "schema_header") { "schema_header may not have extra annotations" }
val imports = loadHeaderImports(context, headerValue)

if (ionSchemaVersion != IonSchemaVersion.v1_0) {
context.userReservedFields = loadUserReservedFieldNames(headerValue)
validateFieldNamesInHeader(context, headerValue)
}

val headerKeywords = when (ionSchemaVersion) {
IonSchemaVersion.v1_0 -> setOf("imports")
IonSchemaVersion.v2_0 -> IonSchema_2_0.HEADER_KEYWORDS
}

val openContent = headerValue.filter { it.fieldName !in headerKeywords }
.map { it.fieldName to it }
.toBag()

return SchemaDocument.Item.Header(imports, context.userReservedFields, openContent)
}

/**
* Constructs a [UserReservedFields] instance for the given Ion Schema header struct.
*/
private fun loadUserReservedFieldNames(header: IonStruct): UserReservedFields {
val userContent = header.getFields("user_reserved_fields")
.islRequireZeroOrOneElements { "'user_reserved_fields' must only appear 0 or 1 times in the schema header" }

userContent ?: return UserReservedFields()

islRequireIonTypeNotNull<IonStruct>(userContent) { "'user_reserved_fields' must be a non-null struct" }
userContent.islRequireOnlyExpectedFieldNames(IonSchema_2_0.TOP_LEVEL_ANNOTATION_KEYWORDS)
islRequire(userContent.typeAnnotations.isEmpty()) { "'user_reserved_fields' may not have any annotations" }
return UserReservedFields(
header = loadUserReservedFieldsSubfield(userContent, "schema_header"),
type = loadUserReservedFieldsSubfield(userContent, "type"),
footer = loadUserReservedFieldsSubfield(userContent, "schema_footer")
)
}

/**
* 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<String> {
return userContent.getIslOptionalField<IonList>(fieldName)
?.islRequireElementType<IonSymbol>("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()
}

private fun loadHeaderImports(context: ReaderContext, header: IonStruct): List<HeaderImport> {
// If there's no imports field, then there's nothing to do
val imports = header.getIslOptionalField<IonList>("imports") ?: return emptyList()

islRequireNoIllegalAnnotations(imports) { "'imports' list may not be annotated" }

return imports.readAllCatching(context) {
islRequireIonTypeNotNull<IonStruct>(it) { "header import must be a non-null struct; was: $this" }
it.islRequireOnlyExpectedFieldNames(IonSchema_2_0.IMPORT_KEYWORDS)
islRequireNoIllegalAnnotations(it) { "import struct may not have any annotations" }

val schemaId = it.getIslRequiredField<IonText>("id").stringValue()
val typeField = it.getIslOptionalField<IonSymbol>("type")
val asField = it.getIslOptionalField<IonSymbol>("as")

when {
asField != null -> {
islRequire(typeField != null) { "'as' only allowed when 'type' is present: $it" }
HeaderImport.Type(schemaId, typeField.stringValue(), asField.stringValue())
}
typeField != null -> HeaderImport.Type(schemaId, typeField.stringValue())
else -> HeaderImport.Wildcard(schemaId)
}
}
}

private fun validateFieldNamesInHeader(context: ReaderContext, header: IonStruct) {
val unexpectedFieldNames = header.map { it.fieldName }
.filterNot {
it in IonSchema_2_0.HEADER_KEYWORDS ||
it in context.userReservedFields.header ||
!IonSchema_2_0.RESERVED_WORDS_REGEX.matches(it)
}
islRequire(unexpectedFieldNames.isEmpty()) { "Found unexpected field names $unexpectedFieldNames in schema header: $header" }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.amazon.ionschema.reader.internal

import com.amazon.ion.system.IonSystemBuilder
import com.amazon.ionschema.InvalidSchemaException
import com.amazon.ionschema.internal.util.IonSchema_2_0
import com.amazon.ionschema.model.ExperimentalIonSchemaModel
import com.amazon.ionschema.model.SchemaDocument
import com.amazon.ionschema.model.UserReservedFields
import com.amazon.ionschema.util.bagOf
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

@OptIn(ExperimentalIonSchemaModel::class)
class FooterReaderTest {

val ION = IonSystemBuilder.standard().build()
private val footerReader = FooterReader { it in userReservedFields.footer || !IonSchema_2_0.RESERVED_WORDS_REGEX.matches(it) }

@Test
fun `readFooter can read an empty footer`() {
val context = ReaderContext()
val footer = footerReader.readFooter(context, ION.singleValue("schema_footer::{}"))
assertEquals(SchemaDocument.Item.Footer(), footer)
}

@Test
fun `readFooter can read a footer with unreserved open content`() {
val context = ReaderContext()
val footer = footerReader.readFooter(context, ION.singleValue("schema_footer::{_foo:1,_bar:2}"))

val expected = SchemaDocument.Item.Footer(
openContent = bagOf(
"_foo" to ION.newInt(1),
"_bar" to ION.newInt(2),
)
)

assertEquals(expected, footer)
}

@Test
fun `readFooter can read a footer with a user reserved field as open content`() {
val context = ReaderContext()
context.userReservedFields = UserReservedFields(footer = setOf("foo", "bar"))
val footer = footerReader.readFooter(context, ION.singleValue("schema_footer::{foo:1,bar:2}"))
val expected = SchemaDocument.Item.Footer(
openContent = bagOf(
"foo" to ION.newInt(1),
"bar" to ION.newInt(2),
)
)

assertEquals(expected, footer)
}

@Test
fun `readFooter throws exception when illegal field is open content`() {
val context = ReaderContext()
assertThrows<InvalidSchemaException> { footerReader.readFooter(context, ION.singleValue("schema_footer::{type:1}")) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package com.amazon.ionschema.reader.internal

import com.amazon.ion.system.IonSystemBuilder
import com.amazon.ionschema.InvalidSchemaException
import com.amazon.ionschema.IonSchemaVersion
import com.amazon.ionschema.model.ExperimentalIonSchemaModel
import com.amazon.ionschema.model.HeaderImport
import com.amazon.ionschema.model.SchemaDocument
import com.amazon.ionschema.model.UserReservedFields
import com.amazon.ionschema.util.bagOf
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

@OptIn(ExperimentalIonSchemaModel::class)
class HeaderReaderTest {
val ION = IonSystemBuilder.standard().build()
private val headerReader = HeaderReader(IonSchemaVersion.v2_0)

@Test
fun `readHeader can read an empty header`() {
val context = ReaderContext()
val header = headerReader.readHeader(context, ION.singleValue("schema_header::{}"))
assertEquals(SchemaDocument.Item.Header(), header)
}

@Test
fun `readHeader can read a wildcard import`() {
val context = ReaderContext()
val header = headerReader.readHeader(context, ION.singleValue("""schema_header::{ imports: [ { id: "foo.isl" } ] }"""))
val expected = SchemaDocument.Item.Header(imports = listOf(HeaderImport.Wildcard("foo.isl")))
assertEquals(expected, header)
}

@Test
fun `readHeader can read a type import`() {
val context = ReaderContext()
val header = headerReader.readHeader(context, ION.singleValue("""schema_header::{ imports: [ { id: "foo.isl", type: bar } ] }"""))
val expected = SchemaDocument.Item.Header(imports = listOf(HeaderImport.Type("foo.isl", "bar")))
assertEquals(expected, header)
}

@Test
fun `readHeader can read an aliased import`() {
val context = ReaderContext()
val header = headerReader.readHeader(context, ION.singleValue("""schema_header::{ imports: [ { id: "foo.isl", type: bar, as: baz } ] }"""))
val expected = SchemaDocument.Item.Header(imports = listOf(HeaderImport.Type("foo.isl", "bar", "baz")))
assertEquals(expected, header)
}

@Test
fun `readHeader can read user reserved fields for schema header`() {
val context = ReaderContext()
val header = headerReader.readHeader(context, ION.singleValue("""schema_header::{ user_reserved_fields: { schema_header: [foo] } }"""))
val expectedUserReservedFields = UserReservedFields(header = setOf("foo"))
val expectedHeader = SchemaDocument.Item.Header(userReservedFields = expectedUserReservedFields)
assertEquals(expectedHeader, header)
assertEquals(expectedUserReservedFields, context.userReservedFields)
}

@Test
fun `readHeader can read user reserved fields for schema footer`() {
val context = ReaderContext()
val header = headerReader.readHeader(context, ION.singleValue("""schema_header::{ user_reserved_fields: { schema_footer: [foo] } }"""))
val expectedUserReservedFields = UserReservedFields(footer = setOf("foo"))
val expectedHeader = SchemaDocument.Item.Header(userReservedFields = expectedUserReservedFields)
assertEquals(expectedHeader, header)
assertEquals(expectedUserReservedFields, context.userReservedFields)
}

@Test
fun `readHeader can read user reserved fields for type`() {
val context = ReaderContext()
val header = headerReader.readHeader(context, ION.singleValue("""schema_header::{ user_reserved_fields: { type: [foo] } }"""))
val expectedUserReservedFields = UserReservedFields(type = setOf("foo"))
val expectedHeader = SchemaDocument.Item.Header(userReservedFields = expectedUserReservedFields)
assertEquals(expectedHeader, header)
assertEquals(expectedUserReservedFields, context.userReservedFields)
}

@Test
fun `readHeader can read a header with unreserved open content`() {
val context = ReaderContext()
val header = headerReader.readHeader(context, ION.singleValue("schema_header::{_foo:1,_bar:2}"))

val expected = SchemaDocument.Item.Header(
openContent = bagOf(
"_foo" to ION.newInt(1),
"_bar" to ION.newInt(2),
)
)
assertEquals(expected, header)
}

@Test
fun `readHeader can read a header with a user reserved field as open content`() {
val context = ReaderContext()
context.userReservedFields = UserReservedFields(footer = setOf("foo", "bar"))
val header = headerReader.readHeader(context, ION.singleValue("schema_header::{foo:1,bar:2,user_reserved_fields:{schema_header:[foo,bar]}}"))
val expectedUserReservedFields = UserReservedFields(header = setOf("foo", "bar"))
val expectedHeader = SchemaDocument.Item.Header(
userReservedFields = expectedUserReservedFields,
openContent = bagOf(
"foo" to ION.newInt(1),
"bar" to ION.newInt(2),
)
)
assertEquals(expectedHeader, header)
assertEquals(expectedUserReservedFields, context.userReservedFields)
}

@Test
fun `readHeader throws exception when illegal field is open content`() {
val context = ReaderContext()
assertThrows<InvalidSchemaException> { headerReader.readHeader(context, ION.singleValue("schema_header::{type:1}")) }
}

@Test
fun `readHeader can read open content for ISL 1,0`() {
val reader = HeaderReader(IonSchemaVersion.v1_0)
val context = ReaderContext()
val header = reader.readHeader(context, ION.singleValue("schema_header::{type:1,foo:2}"))

val expected = SchemaDocument.Item.Header(
openContent = bagOf(
"type" to ION.newInt(1),
"foo" to ION.newInt(2),
)
)
assertEquals(expected, header)
}

@Test
fun `readHeader treats user_reserved_fields as open content for ISL 1,0`() {
val reader = HeaderReader(IonSchemaVersion.v1_0)
val context = ReaderContext()
val header = reader.readHeader(context, ION.singleValue("schema_header::{user_reserved_fields:{ type: [a, b, c] }}"))

val expected = SchemaDocument.Item.Header(
openContent = bagOf(
"user_reserved_fields" to ION.singleValue("{type: [a, b, c]}"),
)
)
assertEquals(expected, header)
// Assert that no user reserved fields are set in the read context
assertEquals(UserReservedFields(), context.userReservedFields)
}
}