From 63c84a76550547b80dfd969cd3218d9fc6bbf1bd Mon Sep 17 00:00:00 2001 From: David Denton Date: Wed, 3 Jan 2024 12:48:42 +0000 Subject: [PATCH] Data4k - new module (#54) --------- Co-authored-by: Albert Latacz --- data4k/README.md | 56 +++++++++++++++ data4k/build.gradle | 8 +++ .../dev/forkhandles/data/DataContainer.kt | 23 ++++++ .../dev/forkhandles/data/DataProperty.kt | 25 +++++++ .../forkhandles/data/JacksonDataContainer.kt | 30 ++++++++ .../dev/forkhandles/data/MapDataContainer.kt | 11 +++ .../forkhandles/lens/DataContainerContract.kt | 70 +++++++++++++++++++ .../lens/JacksonDataContainerTest.kt | 30 ++++++++ .../forkhandles/lens/MapDataContainerTest.kt | 28 ++++++++ settings.gradle.kts | 2 +- versions.properties | 10 +++ 11 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 data4k/README.md create mode 100644 data4k/build.gradle create mode 100644 data4k/src/main/kotlin/dev/forkhandles/data/DataContainer.kt create mode 100644 data4k/src/main/kotlin/dev/forkhandles/data/DataProperty.kt create mode 100644 data4k/src/main/kotlin/dev/forkhandles/data/JacksonDataContainer.kt create mode 100644 data4k/src/main/kotlin/dev/forkhandles/data/MapDataContainer.kt create mode 100644 data4k/src/test/kotlin/dev/forkhandles/lens/DataContainerContract.kt create mode 100644 data4k/src/test/kotlin/dev/forkhandles/lens/JacksonDataContainerTest.kt create mode 100644 data4k/src/test/kotlin/dev/forkhandles/lens/MapDataContainerTest.kt diff --git a/data4k/README.md b/data4k/README.md new file mode 100644 index 00000000..619b6665 --- /dev/null +++ b/data4k/README.md @@ -0,0 +1,56 @@ +# Data4k + +Download +[![.github/workflows/build.yaml](https://github.com/fork-handles/forkhandles/actions/workflows/build.yaml/badge.svg)](https://github.com/fork-handles/forkhandles/actions/workflows/build.yaml) + +GitHub license +codebeat badge + +Library to make working with Data-Oriented programming in Kotlin easier, to extract values from dynamic data structures such as Maps. + +## Installation + +In Gradle, install the ForkHandles BOM and then this module in the dependency block: + +```kotlin +implementation(platform("dev.forkhandles:forkhandles-bom:X.Y.Z")) +implementation("dev.forkhandles:data4k") +``` + +## Usage + +The library defines a DataContainer class and implementations for: + +- Map +- Jackson JSON Node + +To extract data from the underlying data, define wrappers which provides access via delegated-properties: + +```kotlin +class MapBacked(propertySet: Map) : MapDataContainer(propertySet) { + val stringField by field() + val listSubClassField by list(::SubMap) + val objectField by obj(::SubMap) +} + +class SubMap(propertySet: Map) : MapDataContainer(propertySet) { + val stringField by field() +} + +val input = MapBacked( + mapOf( + "stringField" to "string", + "listSubClassField" to listOf( + mapOf("stringField" to "string1"), + mapOf("stringField" to "string2"), + ), + "listStringsField" to listOf("string1", "string2"), + "objectField" to mapOf( + "stringField" to "string" + ) + ) +) + +// then just get the values from the underlying data using the type system. Errors will be thrown for missing/invalid properties +val data: String = input.objectField.stringField +``` diff --git a/data4k/build.gradle b/data4k/build.gradle new file mode 100644 index 00000000..6655030d --- /dev/null +++ b/data4k/build.gradle @@ -0,0 +1,8 @@ +description = 'ForkHandles data-oriented programming library' + +dependencies { + api("org.jetbrains.kotlin:kotlin-reflect:_") + compileOnly("com.fasterxml.jackson.core:jackson-databind:_") + testImplementation("com.fasterxml.jackson.core:jackson-databind:_") + testImplementation("io.strikt:strikt-jvm:_") +} diff --git a/data4k/src/main/kotlin/dev/forkhandles/data/DataContainer.kt b/data4k/src/main/kotlin/dev/forkhandles/data/DataContainer.kt new file mode 100644 index 00000000..b8dbe6ac --- /dev/null +++ b/data4k/src/main/kotlin/dev/forkhandles/data/DataContainer.kt @@ -0,0 +1,23 @@ +package dev.forkhandles.data + +/** + * Superclass for all container implementations. Defines the delegate property classes to extract data from the + * underlying data structure. + */ +@Suppress("UNCHECKED_CAST", "ClassName") +abstract class DataContainer( + protected val data: CONTENT, + existsFn: (CONTENT, String) -> Boolean, + getFn: (CONTENT, String) -> Any? +) { + private val exists: DataContainer.(String) -> Boolean = { existsFn(data, it) } + private val get: DataContainer.(String) -> Any? = { getFn(data, it) } + + inner class field : DataProperty, OUT>(exists, get) + + inner class list(mapFn: (IN) -> OUT) : + DataProperty, List>(exists, { (get(it) as List).map(mapFn) }) + + inner class obj>(mapFn: (CONTENT) -> OUT) : + DataProperty, OUT>(exists, { mapFn(get(it) as CONTENT) }) +} diff --git a/data4k/src/main/kotlin/dev/forkhandles/data/DataProperty.kt b/data4k/src/main/kotlin/dev/forkhandles/data/DataProperty.kt new file mode 100644 index 00000000..7a98ba00 --- /dev/null +++ b/data4k/src/main/kotlin/dev/forkhandles/data/DataProperty.kt @@ -0,0 +1,25 @@ +package dev.forkhandles.data + +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty +import kotlin.reflect.jvm.jvmErasure + +abstract class DataProperty( + private val existsFn: IN.(String) -> Boolean, + private val getFn: IN.(String) -> Any? +) : ReadOnlyProperty { + @Suppress("UNCHECKED_CAST") + override fun getValue(thisRef: IN, property: KProperty<*>): OUT { + val result = thisRef.getFn(property.name) + return when { + result == null -> when { + thisRef.existsFn(property.name) -> throw NoSuchElementException("Value for field <${property.name}> is null") + else -> throw NoSuchElementException("Field <${property.name}> is missing") + } + property.returnType.jvmErasure.isInstance(result) -> result as OUT + + else -> throw NoSuchElementException("Value for field <${property.name}> is not a ${property.returnType.jvmErasure} but ${result.javaClass.kotlin}") + } + } +} + diff --git a/data4k/src/main/kotlin/dev/forkhandles/data/JacksonDataContainer.kt b/data4k/src/main/kotlin/dev/forkhandles/data/JacksonDataContainer.kt new file mode 100644 index 00000000..8de3e22d --- /dev/null +++ b/data4k/src/main/kotlin/dev/forkhandles/data/JacksonDataContainer.kt @@ -0,0 +1,30 @@ +package dev.forkhandles.data + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.* + +/** + * Jackson JsonNode-based implementation of the DataContainer + */ +abstract class JacksonDataContainer(input: JsonNode) : + DataContainer( + input, + { content, it -> content.has(it) }, + { content, it -> content[it]?.let(Companion::nodeToValue) } + ) { + + companion object { + private fun nodeToValue(input: JsonNode): Any? = when (input) { + is BooleanNode -> input.booleanValue() + is IntNode -> input.intValue() + is LongNode -> input.longValue() + is DecimalNode -> input.decimalValue() + is DoubleNode -> input.doubleValue() + is TextNode -> input.textValue() + is ArrayNode -> input.map(::nodeToValue) + is ObjectNode -> input + is NullNode -> null + else -> error("Invalid node type $input") + } + } +} diff --git a/data4k/src/main/kotlin/dev/forkhandles/data/MapDataContainer.kt b/data4k/src/main/kotlin/dev/forkhandles/data/MapDataContainer.kt new file mode 100644 index 00000000..6b1759f2 --- /dev/null +++ b/data4k/src/main/kotlin/dev/forkhandles/data/MapDataContainer.kt @@ -0,0 +1,11 @@ +package dev.forkhandles.data + +import kotlin.collections.Map + +/** + * Map-based implementation of the DataContainer + */ +abstract class MapDataContainer(input: Map) : + DataContainer>(input, { content, it -> content.containsKey(it) }, { content, it -> content[it] }) + + diff --git a/data4k/src/test/kotlin/dev/forkhandles/lens/DataContainerContract.kt b/data4k/src/test/kotlin/dev/forkhandles/lens/DataContainerContract.kt new file mode 100644 index 00000000..b2d503e5 --- /dev/null +++ b/data4k/src/test/kotlin/dev/forkhandles/lens/DataContainerContract.kt @@ -0,0 +1,70 @@ +package dev.forkhandles.lens + +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import strikt.api.expectThrows +import strikt.assertions.isEqualTo +import strikt.assertions.message + +interface MainClassFields { + val stringField: String + val booleanField: Boolean + val intField: Int + val longField: Long + val decimalField: Double + val notAStringField: String + val optionalField: String? + val noSuchField: String + val listSubClassField: List + val listIntsField: List + val listStringsField: List + val objectField: SubClassFields +} + +interface SubClassFields { + val stringField: String + val noSuchField: String +} + +abstract class DataContainerContract { + + abstract fun container(input: Map): MainClassFields + + @Test + fun `can get values from properties`() { + val input = container( + mapOf( + "stringField" to "string", + "optionalField" to "optional", + "booleanField" to true, + "intField" to 123, + "longField" to Long.MAX_VALUE, + "decimalField" to 1.1234, + "notAStringField" to 123, + "listIntsField" to listOf(1, 2, 3), + "listSubClassField" to listOf( + mapOf("stringField" to "string1"), + mapOf("stringField" to "string2"), + ), + "listStringsField" to listOf("string1", "string2"), + "objectField" to mapOf( + "stringField" to "string" + ) + ) + ) + + expectThat(input.stringField).isEqualTo("string") + expectThat(input.optionalField).isEqualTo("optional") + expectThat(input.booleanField).isEqualTo(true) + expectThat(input.intField).isEqualTo(123) + expectThat(input.longField).isEqualTo(Long.MAX_VALUE) + expectThat(input.decimalField).isEqualTo(1.1234) + expectThat(input.listIntsField).isEqualTo(listOf(1, 2, 3)) + expectThat(input.listSubClassField.map { it.stringField }).isEqualTo(listOf("string1", "string2")) + expectThat(input.listStringsField).isEqualTo(listOf("string1", "string2")) + expectThat(input.objectField.stringField).isEqualTo("string") + expectThrows { input.notAStringField }.message.isEqualTo("Value for field is not a class kotlin.String but class kotlin.Int") + expectThrows { input.noSuchField }.message.isEqualTo("Field is missing") + expectThrows { input.objectField.noSuchField }.message.isEqualTo("Field is missing") + } +} diff --git a/data4k/src/test/kotlin/dev/forkhandles/lens/JacksonDataContainerTest.kt b/data4k/src/test/kotlin/dev/forkhandles/lens/JacksonDataContainerTest.kt new file mode 100644 index 00000000..b5cd6dd9 --- /dev/null +++ b/data4k/src/test/kotlin/dev/forkhandles/lens/JacksonDataContainerTest.kt @@ -0,0 +1,30 @@ +package dev.forkhandles.lens + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import dev.forkhandles.data.JacksonDataContainer + +class JacksonDataContainerTest : DataContainerContract() { + + class SubNodeBacked(node: JsonNode) : JacksonDataContainer(node), SubClassFields { + override val stringField by field() + override val noSuchField by field() + } + + class NodeBacked(node: JsonNode) : JacksonDataContainer(node), MainClassFields { + override val stringField by field() + override val optionalField by field() + override val booleanField by field() + override val intField by field() + override val longField by field() + override val decimalField by field() + override val notAStringField by field() + override val noSuchField by field() + override val listSubClassField by list(::SubNodeBacked) + override val listStringsField by list(Any::toString) + override val listIntsField by list { it as Int } + override val objectField by obj(::SubNodeBacked) + } + + override fun container(input: Map) = NodeBacked(ObjectMapper().valueToTree(input)) +} diff --git a/data4k/src/test/kotlin/dev/forkhandles/lens/MapDataContainerTest.kt b/data4k/src/test/kotlin/dev/forkhandles/lens/MapDataContainerTest.kt new file mode 100644 index 00000000..11f14c96 --- /dev/null +++ b/data4k/src/test/kotlin/dev/forkhandles/lens/MapDataContainerTest.kt @@ -0,0 +1,28 @@ +package dev.forkhandles.lens + +import dev.forkhandles.data.MapDataContainer + +class MapDataContainerTest : DataContainerContract() { + + class SubMap(propertySet: Map) : MapDataContainer(propertySet), SubClassFields { + override val stringField by field() + override val noSuchField by field() + } + + class MapBacked(propertySet: Map) : MapDataContainer(propertySet), MainClassFields { + override val stringField by field() + override val optionalField by field() + override val booleanField by field() + override val intField by field() + override val longField by field() + override val decimalField by field() + override val notAStringField by field() + override val noSuchField by field() + override val listSubClassField by list(::SubMap) + override val listStringsField by list(Any::toString) + override val listIntsField by list { it as Int } + override val objectField by obj(::SubMap) + } + + override fun container(input: Map): MainClassFields = MapBacked(input) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8a324e02..e60b3b3c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,8 +18,8 @@ fun String.includeModule(dir: String) { include("forkhandles-bom") include("bunting4k") +include("data4k") include("fabrikate4k") -include("lens4k") include("mock4k") include("parser4k") include("partial4k") diff --git a/versions.properties b/versions.properties index 7df9a8df..6a07db5f 100644 --- a/versions.properties +++ b/versions.properties @@ -13,6 +13,8 @@ plugin.io.codearte.nexus-staging=0.30.0 plugin.me.champeau.jmh=0.7.2 +version.com.fasterxml.jackson.core..jackson-databind=2.16.1 + version.com.natpryce..hamkrest=1.8.0.1 version.junit.jupiter=5.10.1 @@ -25,4 +27,12 @@ version.kotlinx.coroutines=1.7.3 version.kotlinx.serialization=1.6.2 +version.strikt=0.34.1 + +## unused +version.org.openjdk.jmh..jmh-generator-bytecode=1.36 + +## unused +version.org.openjdk.jmh..jmh-core=1.36 + version.org.kt3k.gradle.plugin..coveralls-gradle-plugin=2.8.3