ion-element
is an immutable in-memory representation of Amazon Ion which has an API
that is idiomatic to Kotlin and is also usable from Java. It is meant as an alternative to IonValue
from
ion-java but is not a complete implementation of Ion as it relies on ion-java
's
IonReader
and IonWriter
interfaces for reading and writing Ion data.
While manipulating the IonValue
instances of ion-java
, they often need to be deeply copied, which can be extremely
expensive for large data structures. For instance, a single IonValue
instance cannot reside in multiple IonContainer
instances. If this is desired, one must call IonValue.clone()
. Depending on how the size of IonValue
instances and
how they are manipulated, the cost of calling IonValue.clone()
can quickly add up causing severe performance
degradation.
IonValue
must be cloned before being added to a second container because they...
- ... are mutable. (Mutating a value contained within multiple containers would be very confusing and lead to countless bugs.)
- ... keep a reference to the parent container that they reside in.
- ... may contain symbol ids, which can vary depending on the current Ion symbol table.
- ... know which
IonSystem
they originated from.
None of the above is true about instances of IonElement
. IonElement
instances never need to be cloned.
IonElement
intentionally does not provide any method of performing a deep copy.
This example works perfectly fine with IonElement
instances:
val listElement = loadSingleElement("[{ some_potentially_big_container: 42}]").asList();
val newListElement = ionListOf(listElement.values[0]) // <-- no need to call .clone().
println(newListElement)
// Output is [{ some_potentially_big_container: 42}]
But the equivalent with IonValue
requires a call to .clone()
:
val ion = IonSystemBuilder.standard().build()
val ionList = ion.singleValue("[{ some_potentially_big_container: 42}]") as IonList
// Removal of .clone() below results in com.amazon.ion.ContainedValueException.
val newList = ion.newList(ionList[0].clone())
println(newList)
// Output is [{ some_potentially_big_container: 42}]
In order to access the value of an IonValue
, one must first check to make sure it is safe to down-cast, and then
down-cast it to the appropriate type, but ion-element
provides the interface AnyElement
which conveniently performs
the type before returning the down-casted value. Example:
val elem: AnyElement = loadSingleElement("\"What are you doing, Dave?\"")
println("The string was: ${elem.stringValue}")
This is contrast to the equivalent code using IonValue
here:
val ion = IonSystemBuilder.standard().build()
val ionValue = ion.singleValue("\"What are you doing, Dave?\"")
// Check type of IonValue first to ensure it's safe to cast to `IonString`
if (ionValue.type == IonType.STRING) {
val ionStr = ionValue as IonString // <-- Down cast here
println("The string was: ${ionStr.stringValue()}")
} else {
error("Expected an Ion string, got an IonType.${ionValue.type}");
}
Even though it is an interface, it is not possible to implement ion-java
's IonValue
yourself because ion-java
explicitly forbids it.
This library places no such constraints on the implementations of IonElement
. As long as the behavior is implemented
correctly (see the documentation of each interface), the implementations of IonElement
provided by this library are
fully interoperable with user supplied implementations.
The inheritance hierarchy of IonElement
and its sub-interfaces closely mirrors that of IonValue
except for the
addition of AnyElement
, which provides value accessors and safe down-casting functions. Also, there is no
sub-interface specifically for Ion null values because they require no member functions (such as value getters) in
addition to those which are already defined on IonElement
. A sub-interface for null values would therefore be
redundant.
The IonElement
and AnyElement
interfaces are described as "widened" while the type specific interfaces are
"narrowed". This is in reference to the range of possible data types representable by these interfaces.
IonElement
AnyElement
BoolElement
IntElement
FloatElement
DecimalElement
TimestampElement
TextElement
StringElement
SymbolElement
LobElement
BlobElement
ClobElement
ContainerElement
SeqElement
ListElement
SexpElement
StructElement
AnyElement
is a special sub-interface of IonElement
that can be used by consumers of this library to reduce the
overhead of accessing JVM native versions of the values represented by an IonElement
. This is accomplished with two
categories of accessor functions that perform type checking before returning a down-casted IonElement
instance or
Kotlin native value. As shown below, AnyElement.as*[OrNull]()
functions perform type checking, throw an exception in
the event of a type mismatch, and down-cast to the narrowed IonElement
sub-interface. AnyElement.*Value[OrNull]
properties are similar but return JVM native values directly.
The IonElement.asAnyElement()
interface exists to convert to AnyElement
. Although the implementations
of IonElement
included in ion-element-kotlin
implement this as a simple downcast (i.e. this as AnyElement
), other implementations of IonElement
are free to
implement this however they see fit.
An example using these functions is shown below:
val stockItemIonText = """
stock_item::{
name: "Fantastic Widget",
price: 12.34,
countInStock: 2,
orders: [
{ customerId: 123, state: WA },
{ customerId: 456, state: "HI" }
]
}
"""
val stockItem: StockItem = try {
val stockElement: StructElement = loadSingleElement(stockItemIonText).asStruct()
StockItem(
stockElement["name"].textValue,
stockElement["price"].decimalValue,
stockElement["countInStock"].longValue,
stockElement["orders"].listValues
.map { order -> order.asStruct() }
.map { order ->
Order(
order["customerId"].longValue,
order["state"].textValue
)
}
)
} catch (ex: IonElementConstraintException) {
error("Malformed StockItem: ${ex.message}");
}
More details about these functions are included below.
In order to reduce the need to downcast to the appropriate sub-interface of IonElement
, AnyElement
provides two
read-only properties per type of Ion data: <kotlinType>Value
and <kotlinType>ValueOrNull
, where <jvmType>
is the
JVM Kotlin equivalent of the corresponding Ion type. Each of these properties will check whether the Ion type is
correct for the property called, and if not, will throw IonElementConstraintException
to indicate that the element is
of an unexpected type.
Here is an example using longValue
and stringValue
val anInteger: AnyElement = loadSingleElement("42")
// Prints 42
println(anInteger.longValue)
// throws IonElementConstraintException
anInteger.stringValue
If the AnyElement
instance contains an Ion null value, an IonElementConstraintException
is also thrown. These should
be used when the application wishes to require elements to be non-null.
val nullElement: AnyElement = loadSingleElement("null")
val typedNullElement: AnyElement = loadSingleElement("null.int")
// Both throw IonElementConstraintException
nullElement.longValue
typedNullElement.longValue
The *Value
functions include a null-check so the application doesn't have to, additionally this takes advantage
of Kotlin's null safety, since the values returned from these functions
are guaranteed to be non-null. For values that might legitimately be null, each of *Value
properties has a
corresponding *ValueOrNull
, which converts Ion null values to a Kotlin null
. These should be used when the
application wishes to allow elements to be null.
val nullElement: AnyElement = loadSingleElement("null")
println(nullElement.longValueOrNull)
val typedNullElement: AnyElement = loadSingleElement("null.int")
println(typedNullElement.longValueOrNull)
val nullStringElement: AnyElement = loadSingleElement("null.string")
// throws IonElementConstraintException
nullStringElement.longValueOrNull
Similar to AnyElement
's *Value[OrNull]
properties AnyElement
also provides a number of as*[OrNull]()
functions.
These serve a similar purpose to the former, performing type checking and optionally a null-check, but the latter casts
the AnyElement
to a narrowed IonElement
sub-interface.
Here is an example using asInt()
and asString()
val anInteger: AnyElement = loadSingleElement("42")
// Prints 42
println(anInteger.longValue)
// throws IonElementConstraintException
anInteger.stringValue
As before, if the AnyElement
instance contains an Ion null value, an IonElementConstraintException
is also thrown.
These should be used when the application wishes to require elements to be non-null.
val nullElement: AnyElement = loadSingleElement("null")
val typedNullElement: AnyElement = loadSingleElement("null.int")
// Both throw IonElementConstraintException
nullElement.asInt()
typedNullElement.asInt()
Also as before, each of as*()
properties has a corresponding as*OrNull
function, which converts AnyElement
instance to a narrowed sub-interface of IonElement
or null.
val nullElement: AnyElement = loadSingleElement("null")
// prints: null
println(nullElement.asIntOrNull())
val typedNullElement: AnyElement = loadSingleElement("null.int")
// prints: null
println(typedNullElement.asIntOrNull())
The following guidelines are applied to the API of this package and are good practices to follow for any API accepting
or returning IonElement
types or its sub-types.
- Any function arguments accepting...
- ... any type of Ion data should be of type
IonElement
and not of typeAnyElement
to avoid requiring the use ofIonElement.asAnyElement()
at the call-site. - ... a specific type of Ion data should use the narrowest possible
IonElement
sub-interface, for instance:- A function argument requiring an Ion s-expression should be of type
SexpElement
. - A function argument allowing either a list or an s-expression should be of type
SeqElement
, which is the narrowest type that can represent either.
- A function argument requiring an Ion s-expression should be of type
- ... any type of Ion data should be of type
- Any functions that may return...
- ... any type of Ion data should have the
AnyElement
return type to avoid forcing the caller to down cast to a narrower sub-interface orAnyElement
. - ... more than one type of Ion data should have the narrowest possible return type, for instance:
- A function that will only ever return s-expressions should have the
SexpElement
return type. - A function that may return lists or s-expressions should have the
SeqElement
return type. - A function that may return structs, lists or s-expressions should have the
ContainerElement
return type.
- A function that will only ever return s-expressions should have the
- ... any type of Ion data should have the
IonElement
instances are created without any IonSystem
or other factory object. Instead, IonElement
instances are
created by a series of top-level Kotlin functions (Ion.*
static functions from Java). Not all IonElement
constructor functions are shown here. Please see Ion.kt for a complete list.
Some examples are below:
val greeting = ionString("Hello world!")
println(greeting) // "Hello world!"
val ultimateAnswer = ionInt(42)
println(ultimateAnswer) // 42
val listOfInts = ionListOf((1L..10L).map { ionInt(it) })
println(listOfInts) // [1,2,3,4,5,6,7,8,9,10]
val anotherList = ionListOf(
greeting,
ultimateAnswer,
listOfInts,
annotations = listOf("yolo", "i_love_ion")
)
println(anotherList) // yolo::i_love_ion::["Hello world!",42,[1,2,3,4,5,6,7,8,9,10]]
val person = ionStructOf(
"firstName" to ionString("Frédéric"),
"lastName" to ionString("Chopin"),
"birthdate" to ionTimestamp("1810-02-22")
)
println(person) // {firstName:"Frédéric",lastName:"Chopin",birthdate:1810-02-22})
Each IonElement
constructor has an optional annotations: List<String>
which may be used to specify annotations.
Annotations may also be added to an existing IonElement
with the IonElement.withAnnotations()
and annotations
removed with the IonElement.withoutAnnotations()
extensions functions. Both functions treat the
IonElement
as a persistent data structure; that is: they return a modified shallow copy of the original.
// Creating a new `IonElement` instance with a list annotations:
val ionInt = ionInt(42, annotations = listOf("meaning_of_life"))
// Prints meaning_of_life::42
println(ionInt)
// Adding values to an existing instance
val ionInt2 = ionInt(42)
val ionInt3 = ionInt2.withAnnotations("meaning", "of", "life")
// Prints meaning::of::life::42
println(ionInt3)
// Removing annotations from an existing instance
val ionInt4 = ionInt3.withoutAnnotations()
// Prints 42
println(ionInt4)
Several ways exist to load Ion data from an com.amazon.ion.IonReader
instance or from a String
.
Accepts either a String
or IonReader
, and loads the first value found into an AnyElement
instance.
Throws an exception if there is more than one top level value, which is useful when a guarantee that there is only one
value present is needed.
Signatures:
fun loadSingleElement(ionText: String, options: IonElementLoaderOptions = IonElementLoaderOptions())
fun loadSingleElement(ionReader: IonReader, options: IonElementLoaderOptions = IonElementLoaderOptions())
Example:
val element: AnyElement = loadSingleElement("{ some_field: 42 }")
Loads the entire current top-level Ion value from an IonReader
. Unlike loadSingleElement
, does not throw an
exception if another top-level value remains in the IonReader
. Steps in to and out as needed for container values, but
does not advance the reader to the next top level value.
Signatures:
fun loadCurrentElement(ionReader: IonReader, options: IonElementLoaderOptions = IonElementLoaderOptions()): AnyElement
Example:
IonReaderBuilder.standard().build("1 2").use { reader ->
while (reader.next() != null) {
println(loadCurrentElement(reader))
}
}
This is an alternative to loadCurrentElement
. Returns an iterator that reads one top-level value at a time.
Signatures:
fun loadAllElements(ionText: String, options: IonElementLoaderOptions = IonElementLoaderOptions()): Iterable<AnyElement>
fun loadAllElements(
ionReader: IonReader,
options: IonElementLoaderOptions = IonElementLoaderOptions()
): Iterable<AnyElement>
val allElementsIterator: Iterable<AnyElement> = loadAllElements("1 2")
allElementsIterator.forEach { elem: AnyElement ->
println(elem)
}
All the above functions are also present on the IonElementLoader
interface, which can easily be mocked for the
purposes of isolated unit testing. The primary difference is that the IonElementLoaderOptions
instance is passed to
the createIonElementLoader
function, which creates a real (non-mock) implementation of
IonElementLoader
.
val loader: IonElementLoader = createIonElementLoader(IonElementLoaderOptions(includeLocationMeta = true));
val element: AnyElement = loader.loadSingleElement("{ some_field: 42 }")
The contract of IonValue.toString()
does not guarantee that the method will produce valid Ion text (though in practice
it does produce valid Ion text). The contract of IonElement.toString()
does guarantee that the function must produce
value Ion text. The string representation is produced with a standard IonTextWriter
from ion-java
.
As a rule of thumb, if performance or space considerations are paramount, this should be avoided. It is generally more
performant to write an IonElement
directly to an IonWriter
, shown below.
IonElement.writeTo(IonWriter)
exists for writing Ion data to an Ion binary or text stream.
val meaningOfLife = ionInt(42)
val stringBuilder = StringBuilder()
// Construct an `IonWriter` in the usual way:
val ion = IonSystemBuilder.standard().build()
ion.newTextWriter(stringBuilder).use { writer ->
meaningOfLife.writeTo(writer)
}
// Prints "42"
println(stringBuilder.toString())
IonElement.hashCode
and IonElement.equals
behave identically to IonValue
's--the definition of equivalence is the
same.
Every instance of IonElement
contains a metas: HashMap<String, Any>
that is useful for storing arbitrary metadata
with each node. This metadata does not affect IonElement.equals
or IonElement.hashCode
, and it is currently up to
the application to take care of persisting any metas.
Future work: this library should assist with (de)serializing metas.
In certain contexts, for example, when Ion text is used as the syntax of a DSL (domain specific language) or as a configuration file, it is helpful to know the line & column number of the Ion value within a file so that semantic validation errors may be reported to the end user. This is accomplished through the use of the previously described metas.
The inclusion of location metas comes at an additional CPU cost which is non-trivial for large files, so by default is
disabled. It is enabled by passing IonElementLoaderOptions(includeLocationMeta = true)
along to the corresponding
loader function.
val ionText = "{\n some_field: 42 }"
val structElem: StructElement =
loadSingleElement(ionText, IonElementLoaderOptions(includeLocationMeta = true)).asStruct()
// Struct is positioned on line 1 at character 1.
assertEquals(IonTextLocation(1, 1), structElem.metas.location)
// some_field begins on line 2 at character 3.
assertEquals(IonTextLocation(2, 3), structElem["some_field"].metas.location)
Note that .location
above is an extension property provided for convenience.
Finally, this works for both Ion text and Ion binary files, however for binary files, the meta includes a byte offset instead of a line number and character offset.
In order to support better interoperability with existing code that uses IonValue
, it is easy to convert between
IonElement
and IonValue
instances. The IonValue.toIonElement()
and IonElement.toIonValue(ValueFactory)
extension functions are provided for this purpose.
val messageString = "What are you doing, Dave?"
val ion = IonSystemBuilder.standard().build()
// Converting from IonValue to IonElement
val ionValue = ion.newString(messageString)
val ionElement = ionValue.toIonElement().asString()
assertEquals(ionString(messageString), ionElement)
// Converting from IonElement to IonValue
val ionElement2 = ionString(messageString)
val ionValue2 = ionElement.toIonValue(ion)
assertEquals(ion.newString(messageString), ionValue2)
Currently, there are two possible areas of future development for IonElement
.
- Path copying can be employed to aid in the
transformation of nested deeply nested elements. Today, clients must provide their own solutions for transformation of
deeply nested
IonElement
values. Ideally, this library provides such a facility out of the box. - Lazy loading of
IonElement
values. An alternative toion-java
's skip-scanning abilities, this would avoid parsing the Ion text or binary values until their values are actually requested, and would provide all the benefits of skip-scanning, while also providing a rich Ion object model. By contrast, todayIonElement
(and alsoIonValue
) eagerly read the entire top-level value and populate an entire deeply nested data structure).
This repository contains the ion-tests repository as
a git submodule. The easiest way to clone the ion-element-kotlin
repository
and initialize its submodule is to run the following command:
git clone --recursive https://github.com/amzn/ion-element-kotlin.git ion-element-kotlin
Alternatively, the submodule may be initialized independently of the clone by running the following commands:
git submodule init
git submodule update
ion-element-kotlin
may now be built with the following command:
./gradlew build
This project uses ktlint to enforce the official Kotlin code conventions. To automatically correct (most) style violations, run:
./gradlew ktlintFormat
This project uses Kotlin/binary-compatibility-validator to
prevent accidental changes to the binary API of the library. The apiCheck
task will be run as part of the Gradle
check
task, and it will fail if the current build differs in any way from the API that is persisted in api/IonElement.api
.
If you are intentionally changing the API, update the persisted API definition by running:
./gradlew apiDump && git add api
When preparing a release, you can use the diff of api/IonElement.api
to help determine whether a release should be a
new major, minor, or patch version. If the file has no changes, it may be a patch release. If the file has only
additive changes, it may be a minor version. Any other changes indicate that a new major version is required.
To pull upstream changes into ion-element-kotlin
, start with a simple git pull
. This will pull in any changes
to ion-element-kotlin
itself (including any changes to its .gitmodules
file), but not any changes to the submodules.
To make sure the submodules are up-to-date, use the following command:
git submodule update --remote
For detailed walkthroughs of git submodule usage, see the Git Tools documentation.
This project is licensed under the Apache-2.0 License.