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

Improve sealed class path generation #14

Merged
merged 2 commits into from
Feb 4, 2025
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
47 changes: 47 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,53 @@ data class Merchant(
)
```

### Sealed (Subclass) Classes
Sealed and subclasses are supported, but there are some caveats, as searching on json paths on works
on data that is serialized to json. I.e. getters/setters are not queryable.

Take the following example:

```kotlin
sealed class Card {
val id: Uuid
val cardNumber: String
@Serializable
data class CreditCard(
val key: Uuid,
val last4: String,
val expiry: String,
) : Card() {
override val id: Uuid get() = key
override val cardNumber: String get() = last4
}

@Serializable
data class DebitCard(
val key: Uuid,
val last4: String,
val expiry: String,
) : Card() {
override val id: Uuid get() = key
override val cardNumber: String get() = last4
}
}
```

As `id` and `cardNumber` are abstract properties of the sealed class, they never get serialized to
json, so they would not be queryable. (Unless you made your concrete classes override and serialize them.)

`with` will accepts sub types of the parent class, please open issues of complex data structures if stuck.

The following would be valid and invalid queries:
```kotlin
// These will search across the sealed class fields
val idWhere = Card::class.with(CreditCard::key) eq "1"
val last4Where = Card::class.with(CreditCard::last4) eq "1234"
// This will not work tho as cardNumber is a getter
val cardNumberWhere = Card::cardNumber eq "1234"

```

## Installation

### Gradle
Expand Down
62 changes: 48 additions & 14 deletions library/src/commonMain/kotlin/com/mercury/sqkon/db/JsonPath.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import kotlinx.serialization.descriptors.StructureKind
import kotlinx.serialization.serializer
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.KType
import kotlin.reflect.typeOf

/**
Expand Down Expand Up @@ -58,6 +59,25 @@ class JsonPathBuilder<R : Any>
return this
}

@PublishedApi
internal inline fun <reified R1 : R, reified V> with(
baseType: KType,
property: KProperty1<R1, V>,
serialName: String? = null,
block: JsonPathNode<R, V>.() -> Unit = {}
): JsonPathBuilder<R> {
parentNode = JsonPathNode<R, V>(
//parent = null,
propertyName = serialName ?: property.name,
receiverBaseDescriptor = if (baseType != typeOf<R1>()) {
serializer(baseType).descriptor
} else null,
receiverDescriptor = serializer<R1>().descriptor,
valueDescriptor = serializer<V>().descriptor
).also(block)
return this
}

// Handles collection property type and extracts the element type vs the list type
@PublishedApi
internal inline fun <reified R1 : R, reified V : Any?> withList(
Expand All @@ -79,29 +99,37 @@ class JsonPathBuilder<R : Any>
val nodes = mutableListOf<JsonPathNode<*, *>>()
var node: JsonPathNode<*, *>? = parentNode
while (node != null) {
// Insert additional node for parent classes incase they are sealed classes
if (node.receiverBaseDescriptor != null) {
nodes.add(
JsonPathNode<Any, Any>(
propertyName = "",
receiverDescriptor = node.receiverBaseDescriptor,
valueDescriptor = node.receiverBaseDescriptor
)
)
}
nodes.add(node)
node = node.child
}
return nodes.filterInlineClasses()
}

private fun List<JsonPathNode<*, *>>.filterInlineClasses(): List<JsonPathNode<*, *>> {
return this.filter { node -> return@filter !node.receiverDescriptor.isInline }
return nodes
}

@OptIn(ExperimentalSerializationApi::class)
fun fieldNames(): List<String> {
return nodes().map {
return nodes().mapNotNull { it ->
if (it.receiverDescriptor.isInline) return@mapNotNull null // Skip inline classes
val prefix = if (it.propertyName.isNotBlank()) "." else ""
when (it.valueDescriptor.kind) {
StructureKind.LIST -> "${it.propertyName}[%]"
PolymorphicKind.SEALED -> "${it.propertyName}[1]"
else -> it.propertyName
StructureKind.LIST -> "$prefix${it.propertyName}[%]"
PolymorphicKind.SEALED -> "$prefix${it.propertyName}[1]"
else -> "$prefix${it.propertyName}"
}
}
}

fun buildPath(): String {
return fieldNames().joinToString(".", prefix = "\$.")
return fieldNames().joinToString("", prefix = "\$")
}
}

Expand Down Expand Up @@ -154,12 +182,13 @@ inline fun <reified R : Any, reified V, reified V2> KProperty1<R, V>.thenList(
}
}

@Suppress("UnusedReceiverParameter")
inline fun <reified R : Any, reified V> KClass<R>.with(
property: KProperty1<R, V>,
inline fun <reified R : Any, reified R1 : R, reified V> KClass<R>.with(
property: KProperty1<R1, V>,
block: JsonPathNode<R, V>.() -> Unit = {}
): JsonPathBuilder<R> {
return JsonPathBuilder<R>().with<R, V>(property = property, block = block)
return JsonPathBuilder<R>().with<R1, V>(
baseType = typeOf<R>(), property = property, block = block
)
}

// Handles collection property type
Expand All @@ -181,6 +210,7 @@ class JsonPathNode<R : Any?, V : Any?>
internal constructor(
//@PublishedApi internal val parent: JsonPathNode<*, R>?,
val propertyName: String,
internal val receiverBaseDescriptor: SerialDescriptor? = null,
internal val receiverDescriptor: SerialDescriptor,
@PublishedApi internal val valueDescriptor: SerialDescriptor,
) {
Expand Down Expand Up @@ -224,6 +254,10 @@ internal constructor(
).also(block)
return this
}

override fun toString(): String {
return "JsonPathNode(propertyName='$propertyName', receiverDescriptor=$receiverDescriptor, valueDescriptor=$valueDescriptor)"
}
}

//private fun testBlock() {
Expand Down
36 changes: 36 additions & 0 deletions library/src/commonTest/kotlin/com/mercury/sqkon/TestDataClasses.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,39 @@ sealed interface TestSealed {
value class Impl2(val value: String) : TestSealed

}

@Serializable
sealed interface BaseSealed {

val id: String

@JvmInline
@Serializable
@SerialName("TypeOne")
value class TypeOne(
val data: TypeOneData
) : BaseSealed {
override val id: String get() = data.key
}

@Serializable
@SerialName("TypeTwo")
data class TypeTwo(
val data: TypeTwoData
) : BaseSealed {
override val id: String get() = data.key
}

}

@Serializable
data class TypeOneData(
val key: String,
val value: String
)

@Serializable
data class TypeTwoData(
val key: String,
val otherValue: Int
)
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.mercury.sqkon.db

import com.mercury.sqkon.BaseSealed
import com.mercury.sqkon.TestObject
import com.mercury.sqkon.TestObjectChild
import com.mercury.sqkon.TestSealed
import com.mercury.sqkon.TestValue
import com.mercury.sqkon.TypeOneData
import com.mercury.sqkon.TypeTwoData
import org.junit.Test
import kotlin.test.assertEquals

Expand Down Expand Up @@ -76,6 +79,22 @@ class JsonPathBuilderTest {
assertEquals(expected = "\$.sealed[1]", actual = builder.buildPath())
}

@Test
fun build_with_base_sealed_value_path() {
val builder = BaseSealed::class.with(BaseSealed.TypeOne::data) {
then(TypeOneData::key)
}
assertEquals(expected = "\$[1].key", actual = builder.buildPath())
}

@Test
fun build_with_base_sealed_data_path() {
val builder = BaseSealed::class.with(BaseSealed.TypeTwo::data) {
then(TypeTwoData::key)
}
assertEquals(expected = "\$[1].data.key", actual = builder.buildPath())
}

@Test
fun build_with_list_builder() {
val builder = TestObject::list.builderFromList {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
@file:OptIn(ExperimentalUuidApi::class)

package com.mercury.sqkon.db

import com.mercury.sqkon.BaseSealed
import com.mercury.sqkon.TestObject
import com.mercury.sqkon.TestSealed
import com.mercury.sqkon.TypeOneData
import com.mercury.sqkon.TypeTwoData
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

class KeyValueStorageSealedTest {

private val mainScope = MainScope()
private val driver = driverFactory().createDriver()
private val entityQueries = EntityQueries(driver)
private val metadataQueries = MetadataQueries(driver)
private val testObjectStorage = keyValueStorage<TestObject>(
"test-object", entityQueries, metadataQueries, mainScope
)

private val baseSealedStorage = keyValueStorage<BaseSealed>(
"test-object", entityQueries, metadataQueries, mainScope
)

@AfterTest
fun tearDown() {
mainScope.cancel()
}


@Test
fun select_byEntitySealedImpl() = runTest {
val expected = (1..10).map {
TestObject(sealed = TestSealed.Impl(boolean = true))
}.associateBy { it.id }
testObjectStorage.insertAll(expected)
val actualBySealedBooleanFalse = testObjectStorage.select(
where = TestObject::sealed.then(TestSealed.Impl::boolean) eq false,
).first()
val actualBySealedBooleanTrue = testObjectStorage.select(
where = TestObject::sealed.then(TestSealed.Impl::boolean) eq true,
).first()

assertEquals(0, actualBySealedBooleanFalse.size)
assertEquals(expected.size, actualBySealedBooleanTrue.size)
}

@Test
fun select_byEntitySealedImpl2() = runTest {
val expected = (1..10).map {
TestObject(sealed = TestSealed.Impl2(value = "test value"))
}.associateBy { it.id }
testObjectStorage.insertAll(expected)
val actualBySealedValue1 = testObjectStorage.select(
where = TestObject::sealed.then(TestSealed.Impl2::value) eq "test",
).first()
val actualBySealedValue2 = testObjectStorage.select(
where = TestObject::sealed.then(TestSealed.Impl2::value) eq "test value",
).first()

assertEquals(0, actualBySealedValue1.size)
assertEquals(expected.size, actualBySealedValue2.size)
}

@org.junit.Test
fun select_byBaseSealedId() = runTest {
val expectedT1 = (1..10).map {
BaseSealed.TypeOne(
data = TypeOneData(key = it.toString(), value = Uuid.random().toString())
)
}.associateBy { it.id }
val exceptedT2 = (11..20).map {
BaseSealed.TypeTwo(
data = TypeTwoData(key = it.toString(), otherValue = it)
)
}.associateBy { it.id }
baseSealedStorage.insertAll(expectedT1 + exceptedT2)
val count = baseSealedStorage.count().first()
assertEquals(20, count)

val actualT1 = baseSealedStorage.select(
where = BaseSealed::class.with(BaseSealed.TypeOne::data) {
then(TypeOneData::key)
} eq "1",
).first()
assertEquals(1, actualT1.size)
assertEquals(expectedT1["1"], actualT1.first() as BaseSealed.TypeOne)
val actualT2 = baseSealedStorage.select(
where = BaseSealed::class.with(BaseSealed.TypeTwo::data) {
then(TypeTwoData::key)
} eq "11",
).first()
assertEquals(1, actualT2.size)
assertEquals(exceptedT2["11"], actualT2.first() as BaseSealed.TypeTwo)

// Can't search on a getter
baseSealedStorage.select(where = BaseSealed::id.eq("1")).first().also {
assertEquals(0, it.size)
}
}
}
Loading