Skip to content

Commit

Permalink
Data4k - new module (#54)
Browse files Browse the repository at this point in the history

---------

Co-authored-by: Albert Latacz <albert.latacz@gmail.com>
  • Loading branch information
daviddenton and albertlatacz authored Jan 3, 2024
1 parent 72a5715 commit 63c84a7
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 1 deletion.
56 changes: 56 additions & 0 deletions data4k/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Data4k

<a href="https://mvnrepository.com/artifact/dev.forkhandles"><img alt="Download" src="https://img.shields.io/maven-central/v/dev.forkhandles/forkhandles-bom"></a>
[![.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)
<a href="https://codecov.io/gh/fork-handles/forkhandles"><img src="https://codecov.io/gh/fork-handles/forkhandles/branch/trunk/graph/badge.svg"/></a>
<a href="http//www.apache.org/licenses/LICENSE-2.0"><img alt="GitHub license" src="https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat"></a>
<a href="https://codebeat.co/projects/github-com-fork-handles-forkhandles-trunk"><img alt="codebeat badge" src="https://codebeat.co/badges/5b369ed4-af27-46f4-ad9c-a307d900617e"></a>

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<String, Any?>
- Jackson JSON Node

To extract data from the underlying data, define wrappers which provides access via delegated-properties:

```kotlin
class MapBacked(propertySet: Map<String, Any?>) : MapDataContainer(propertySet) {
val stringField by field<String>()
val listSubClassField by list(::SubMap)
val objectField by obj(::SubMap)
}

class SubMap(propertySet: Map<String, Any?>) : MapDataContainer(propertySet) {
val stringField by field<String>()
}

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
```
8 changes: 8 additions & 0 deletions data4k/build.gradle
Original file line number Diff line number Diff line change
@@ -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:_")
}
23 changes: 23 additions & 0 deletions data4k/src/main/kotlin/dev/forkhandles/data/DataContainer.kt
Original file line number Diff line number Diff line change
@@ -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<CONTENT>(
protected val data: CONTENT,
existsFn: (CONTENT, String) -> Boolean,
getFn: (CONTENT, String) -> Any?
) {
private val exists: DataContainer<CONTENT>.(String) -> Boolean = { existsFn(data, it) }
private val get: DataContainer<CONTENT>.(String) -> Any? = { getFn(data, it) }

inner class field<OUT> : DataProperty<DataContainer<CONTENT>, OUT>(exists, get)

inner class list<IN : Any, OUT>(mapFn: (IN) -> OUT) :
DataProperty<DataContainer<CONTENT>, List<OUT>>(exists, { (get(it) as List<IN>).map(mapFn) })

inner class obj<OUT : DataContainer<CONTENT>>(mapFn: (CONTENT) -> OUT) :
DataProperty<DataContainer<CONTENT>, OUT>(exists, { mapFn(get(it) as CONTENT) })
}
25 changes: 25 additions & 0 deletions data4k/src/main/kotlin/dev/forkhandles/data/DataProperty.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package dev.forkhandles.data

import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
import kotlin.reflect.jvm.jvmErasure

abstract class DataProperty<IN, OUT>(
private val existsFn: IN.(String) -> Boolean,
private val getFn: IN.(String) -> Any?
) : ReadOnlyProperty<IN, OUT> {
@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}")
}
}
}

Original file line number Diff line number Diff line change
@@ -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<JsonNode>(
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")
}
}
}
11 changes: 11 additions & 0 deletions data4k/src/main/kotlin/dev/forkhandles/data/MapDataContainer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dev.forkhandles.data

import kotlin.collections.Map

/**
* Map-based implementation of the DataContainer
*/
abstract class MapDataContainer(input: Map<String, Any?>) :
DataContainer<Map<String, Any?>>(input, { content, it -> content.containsKey(it) }, { content, it -> content[it] })


Original file line number Diff line number Diff line change
@@ -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<SubClassFields>
val listIntsField: List<Int>
val listStringsField: List<String>
val objectField: SubClassFields
}

interface SubClassFields {
val stringField: String
val noSuchField: String
}

abstract class DataContainerContract {

abstract fun container(input: Map<String, Any?>): 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<NoSuchElementException> { input.notAStringField }.message.isEqualTo("Value for field <notAStringField> is not a class kotlin.String but class kotlin.Int")
expectThrows<NoSuchElementException> { input.noSuchField }.message.isEqualTo("Field <noSuchField> is missing")
expectThrows<NoSuchElementException> { input.objectField.noSuchField }.message.isEqualTo("Field <noSuchField> is missing")
}
}
Original file line number Diff line number Diff line change
@@ -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<String>()
override val noSuchField by field<String>()
}

class NodeBacked(node: JsonNode) : JacksonDataContainer(node), MainClassFields {
override val stringField by field<String>()
override val optionalField by field<String?>()
override val booleanField by field<Boolean>()
override val intField by field<Int>()
override val longField by field<Long>()
override val decimalField by field<Double>()
override val notAStringField by field<String>()
override val noSuchField by field<String>()
override val listSubClassField by list(::SubNodeBacked)
override val listStringsField by list(Any::toString)
override val listIntsField by list<Any, Int> { it as Int }
override val objectField by obj(::SubNodeBacked)
}

override fun container(input: Map<String, Any?>) = NodeBacked(ObjectMapper().valueToTree(input))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package dev.forkhandles.lens

import dev.forkhandles.data.MapDataContainer

class MapDataContainerTest : DataContainerContract() {

class SubMap(propertySet: Map<String, Any?>) : MapDataContainer(propertySet), SubClassFields {
override val stringField by field<String>()
override val noSuchField by field<String>()
}

class MapBacked(propertySet: Map<String, Any?>) : MapDataContainer(propertySet), MainClassFields {
override val stringField by field<String>()
override val optionalField by field<String?>()
override val booleanField by field<Boolean>()
override val intField by field<Int>()
override val longField by field<Long>()
override val decimalField by field<Double>()
override val notAStringField by field<String>()
override val noSuchField by field<String>()
override val listSubClassField by list(::SubMap)
override val listStringsField by list(Any::toString)
override val listIntsField by list<Any, Int> { it as Int }
override val objectField by obj(::SubMap)
}

override fun container(input: Map<String, Any?>): MainClassFields = MapBacked(input)
}
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions versions.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

0 comments on commit 63c84a7

Please sign in to comment.