fix #75 simple classes JSON serialization with custom formats

This commit is contained in:
Sergey Chernov 2025-12-04 23:58:31 +01:00
parent 0c31ec63ee
commit 84f2f8fac4
3 changed files with 173 additions and 63 deletions

View File

@ -1,17 +1,69 @@
# Json support # Json support
Since 1.0.5 we start adding JSON support. Since 1.0.5 we start adding JSON support. Versions 1,0,6* support serialization of the basic types, including lists and maps, and simple classes. Multiple inheritance may produce incorrect results, it is work in progress.
Right now we only support basic types: maps, lists, strings, numbers, booleans. It is not yet capable of serializing classes. This functionality will be added in the 1.0.6 release. ## Serialization in Lyng
## Serializae to kotlin string
// in lyng // in lyng
assertEquals("{\"a\":1}", {a: 1}.toJsonString()) assertEquals("{\"a\":1}", {a: 1}.toJsonString())
void void
>>> void >>> void
From the kotln side, you can use `Obj.toJson()` and deserialization helpers: Simple classes serialization is supported:
import lyng.serialization
class Point(foo,bar) {
val t = 42
}
// val is not serialized
assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() )
>>> void
Note that mutable members are serialized:
import lyng.serialization
class Point2(foo,bar) {
var reason = 42
// but we override json serialization:
fun toJsonObject() {
{ "custom": true }
}
}
// var is serialized instead
assertEquals( "{\"custom\":true}", Point2(1,2).toJsonString() )
>>> void
Custom serialization of user classes is possible by overriding `toJsonObject` method. It must return an object which is serializable to Json. Most often it is a map, but any object is accepted, that makes it very flexible:
import lyng.serialization
class Point2(foo,bar) {
var reason = 42
// but we override json serialization:
fun toJsonObject() {
{ "custom": true }
}
}
class Custom {
fun toJsonObject() {
"full freedom"
}
}
// var is serialized instead
assertEquals( "\"full freedom\"", Custom().toJsonString() )
>>> void
Please note that `toJsonString` should be used to get serialized string representation of the object. Don't call `toJsonObject` directly, it is not intended to be used outside the serialization library.
## Kotlin side interfaces
The "Batteries included" principle is also applied to serialization.
- `Obj.toJson()` provides Kotlin `JsonElement`
- `Obj.toJsonString()` provides Json string representation
- `Obj.decodeSerializableWith()` and `Obj.decodeSerializable()` allows to decode Lyng classes as Kotlin objects using `kotlinx.serialization`:
```kotlin ```kotlin
/** /**
@ -38,6 +90,6 @@ suspend inline fun <reified T>Obj.decodeSerializable(scope: Scope= Scope()) =
decodeSerializableWith<T>(serializer<T>(), scope) decodeSerializableWith<T>(serializer<T>(), scope)
``` ```
Note that lyng-2-kotlin deserialization with `kotlinx.serialization` is working based on JsonElement as information carrier, without formatting and parsing actual Json strings. This is why we use `Json.decodeFromJsonElement` instead of `Json.decodeFromString`. Such approach gives satisfactory performance without writing and supporting custom `kotlinx.serialization` codecs. Note that lyng-2-kotlin deserialization with `kotlinx.serialization` uses JsonElement as information carrier without formatting and parsing actual Json strings. This is why we use `Json.decodeFromJsonElement` instead of `Json.decodeFromString`. Such an approach gives satisfactory performance without writing and supporting custom `kotlinx.serialization` codecs.

View File

@ -232,8 +232,11 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
?: scope.raiseError("can't serialize non-serializable object (no constructor meta)") ?: scope.raiseError("can't serialize non-serializable object (no constructor meta)")
for (entry in meta.params) for (entry in meta.params)
result[entry.name] = readField(scope, entry.name).value.toJson(scope) result[entry.name] = readField(scope, entry.name).value.toJson(scope)
for (i in serializingVars) for (i in serializingVars) {
result[i.key] = i.value.value.toJson(scope) // remove T:: prefix from the field name for JSON
val parts = i.key.split("::")
result[if( parts.size == 1 ) parts[0] else parts.last()] = i.value.value.toJson(scope)
}
return JsonObject(result) return JsonObject(result)
} }

View File

@ -928,10 +928,10 @@ class ScriptTest {
assertEquals(7, c.eval("x++").toInt()) assertEquals(7, c.eval("x++").toInt())
assertEquals( assertEquals(
8, c.eval("x") 8, c.eval("x")
.also { .also {
println("${it.toDouble()} ${it.toInt()} ${it.toLong()} ${it.toInt()}") println("${it.toDouble()} ${it.toInt()} ${it.toLong()} ${it.toInt()}")
} }
.toInt()) .toInt())
} }
@Test @Test
@ -1878,7 +1878,7 @@ class ScriptTest {
@Test @Test
fun testIntExponentRealForm() = runTest { fun testIntExponentRealForm() = runTest {
when(val x = eval("1e-6").toString()) { when (val x = eval("1e-6").toString()) {
"0.000001", "1E-6", "1e-6" -> {} "0.000001", "1E-6", "1e-6" -> {}
else -> fail("Excepted 1e-6 got $x") else -> fail("Excepted 1e-6 got $x")
} }
@ -2577,14 +2577,16 @@ class ScriptTest {
@Test @Test
fun testSetAddRemoveSet() = runTest { fun testSetAddRemoveSet() = runTest {
eval(""" eval(
"""
val s1 = Set( 1, 2 3) val s1 = Set( 1, 2 3)
val s2 = Set( 3, 4 5) val s2 = Set( 3, 4 5)
assertEquals( Set(1,2,3,4,5), s1 + s2 ) assertEquals( Set(1,2,3,4,5), s1 + s2 )
assertEquals( Set(1,2,3,4,5), s1 + s2.toList() ) assertEquals( Set(1,2,3,4,5), s1 + s2.toList() )
assertEquals( Set(1,2), s1 - s2 ) assertEquals( Set(1,2), s1 - s2 )
assertEquals( Set(1,2), s1 - s2.toList() ) assertEquals( Set(1,2), s1 - s2.toList() )
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
@ -2651,7 +2653,7 @@ class ScriptTest {
) )
} }
class ObjTestFoo(val value: ObjString): Obj() { class ObjTestFoo(val value: ObjString) : Obj() {
override val objClass: ObjClass = klass override val objClass: ObjClass = klass
@ -2669,13 +2671,15 @@ class ScriptTest {
fun TestApplyFromKotlin() = runTest { fun TestApplyFromKotlin() = runTest {
val scope = Script.newScope() val scope = Script.newScope()
scope.addConst("testfoo", ObjTestFoo(ObjString("bar2"))) scope.addConst("testfoo", ObjTestFoo(ObjString("bar2")))
scope.eval(""" scope.eval(
"""
assertEquals(testfoo.test(), "bar2") assertEquals(testfoo.test(), "bar2")
testfoo.apply { testfoo.apply {
println(test()) println(test())
assertEquals(test(), "bar2") assertEquals(test(), "bar2")
} }
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
@ -2683,7 +2687,8 @@ class ScriptTest {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
withTimeout(1.seconds) { withTimeout(1.seconds) {
val s = Script.newScope() val s = Script.newScope()
s.eval(""" s.eval(
"""
fun dosomething() { fun dosomething() {
var x = 0 var x = 0
for( i in 1..100) { for( i in 1..100) {
@ -2692,7 +2697,8 @@ class ScriptTest {
delay(100) delay(100)
assert(x == 5050) assert(x == 5050)
} }
""".trimIndent()) """.trimIndent()
)
(0..100).map { (0..100).map {
globalDefer { globalDefer {
s.eval("dosomething()") s.eval("dosomething()")
@ -2707,7 +2713,8 @@ class ScriptTest {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
withTimeout(1.seconds) { withTimeout(1.seconds) {
val s = Script.newScope() val s = Script.newScope()
s.eval(""" s.eval(
"""
// it is intentionally not optimal to provoke // it is intentionally not optimal to provoke
// RC errors: // RC errors:
class AtomicCounter { class AtomicCounter {
@ -2742,7 +2749,8 @@ class ScriptTest {
} }
assertEquals( 100, ac.getCounter() ) assertEquals( 100, ac.getCounter() )
""".trimIndent()) """.trimIndent()
)
} }
} }
} }
@ -3580,7 +3588,7 @@ class ScriptTest {
} }
// @Test // @Test
fun testMinimumOptimization() = runTest { fun testMinimumOptimization() = runTest {
for (i in 1..200) { for (i in 1..200) {
bm { bm {
@ -3627,16 +3635,24 @@ class ScriptTest {
val scope1 = Script.newScope() val scope1 = Script.newScope()
// extension foo should be local to scope1 // extension foo should be local to scope1
assertEquals("a_foo", scope1.eval(""" assertEquals(
"a_foo", scope1.eval(
"""
fun String.foo() { this + "_foo" } fun String.foo() { this + "_foo" }
"a".foo() "a".foo()
""".trimIndent()).toString()) """.trimIndent()
).toString()
)
val scope2 = Script.newScope() val scope2 = Script.newScope()
assertEquals("a_bar", scope2.eval(""" assertEquals(
"a_bar", scope2.eval(
"""
fun String.foo() { this + "_bar" } fun String.foo() { this + "_bar" }
"a".foo() "a".foo()
""".trimIndent()).toString()) """.trimIndent()
).toString()
)
} }
@Test @Test
@ -3658,15 +3674,18 @@ class ScriptTest {
@Test @Test
fun testRangeIsIterable() = runTest { fun testRangeIsIterable() = runTest {
eval(""" eval(
"""
val r = 1..10 val r = 1..10
assert( r is Iterable ) assert( r is Iterable )
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testCallAndResultOrder() = runTest { fun testCallAndResultOrder() = runTest {
eval(""" eval(
"""
import lyng.stdlib import lyng.stdlib
fun test(a="a", b="b", c="c") { [a, b, c] } fun test(a="a", b="b", c="c") { [a, b, c] }
@ -3681,13 +3700,15 @@ class ScriptTest {
// the parentheses here are in fact unnecessary: // the parentheses here are in fact unnecessary:
val ok2 = test { void }.last() val ok2 = test { void }.last()
assert( ok2 is Callable) assert( ok2 is Callable)
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testIterableMinMax() = runTest { fun testIterableMinMax() = runTest {
eval(""" eval(
"""
import lyng.stdlib import lyng.stdlib
assertEquals( -100, (1..100).toList().minOf { -it } ) assertEquals( -100, (1..100).toList().minOf { -it } )
assertEquals( -1, (1..100).toList().maxOf { -it } ) assertEquals( -1, (1..100).toList().maxOf { -it } )
@ -3764,29 +3785,34 @@ class ScriptTest {
@Test @Test
fun testInlineArrayLiteral() = runTest { fun testInlineArrayLiteral() = runTest {
eval(""" eval(
"""
val res = [] val res = []
for( i in [4,3,1] ) { for( i in [4,3,1] ) {
res.add(i) res.add(i)
} }
assertEquals( [4,3,1], res ) assertEquals( [4,3,1], res )
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testInlineMapLiteral() = runTest { fun testInlineMapLiteral() = runTest {
eval(""" eval(
"""
val res = {} val res = {}
for( i in {foo: "bar"} ) { for( i in {foo: "bar"} ) {
res[i.key] = i.value res[i.key] = i.value
} }
assertEquals( {foo: "bar"}, res ) assertEquals( {foo: "bar"}, res )
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testCommentsInClassConstructor() = runTest { fun testCommentsInClassConstructor() = runTest {
eval(""" eval(
"""
class T( class T(
// comment 1 // comment 1
val x: Int, val x: Int,
@ -3801,7 +3827,7 @@ class ScriptTest {
} }
@Serializable @Serializable
data class JSTest1(val foo: String,val one: Int, val ok: Boolean) data class JSTest1(val foo: String, val one: Int, val ok: Boolean)
@Test @Test
fun testToJson() = runTest { fun testToJson() = runTest {
@ -3810,26 +3836,30 @@ class ScriptTest {
assertEquals(x.toJson().toString(), """{"foo":"bar","one":1,"ok":true}""") assertEquals(x.toJson().toString(), """{"foo":"bar","one":1,"ok":true}""")
assertEquals( assertEquals(
(eval("""{ "foo": "bar", "one": 1, "ok": true }.toJsonString()""") as ObjString).value, (eval("""{ "foo": "bar", "one": 1, "ok": true }.toJsonString()""") as ObjString).value,
"""{"foo":"bar","one":1,"ok":true}""") """{"foo":"bar","one":1,"ok":true}"""
)
println(x.decodeSerializable<JSTest1>()) println(x.decodeSerializable<JSTest1>())
assertEquals(JSTest1("bar", 1, true), x.decodeSerializable<JSTest1>()) assertEquals(JSTest1("bar", 1, true), x.decodeSerializable<JSTest1>())
} }
@Test @Test
fun testInstanceVars() = runTest { fun testInstanceVars() = runTest {
var x = eval(""" var x = eval(
"""
// in this case, x and y are constructor parameters, not instance variables: // in this case, x and y are constructor parameters, not instance variables:
class T(x,y) { class T(x,y) {
// and z is val and therefore needn't serialization either: // and z is val and therefore needn't serialization either:
val z = x + y val z = x + y
} }
T(1, 2) T(1, 2)
""".trimIndent()) as ObjInstance """.trimIndent()
println(x.serializingVars.map { "${it.key}=${it.value.value}"}) ) as ObjInstance
println(x.serializingVars.map { "${it.key}=${it.value.value}" })
// so serializingVars is empty: // so serializingVars is empty:
assertEquals(emptyMap(), x.serializingVars) assertEquals(emptyMap(), x.serializingVars)
x = eval(""" x = eval(
"""
class T(x,y) { class T(x,y) {
// variable z though should be serialized: // variable z though should be serialized:
var z = x + y var z = x + y
@ -3838,17 +3868,19 @@ class ScriptTest {
x.z = 100 x.z = 100
assertEquals(100, x.z) assertEquals(100, x.z)
x x
""".trimIndent()) as ObjInstance """.trimIndent()
) as ObjInstance
// z is instance var, it must present // z is instance var, it must present
val z = x.serializingVars["z"] ?: x.serializingVars["T::z"] val z = x.serializingVars["z"] ?: x.serializingVars["T::z"]
// and be mutable: // and be mutable:
assertTrue( z!!.isMutable ) assertTrue(z!!.isMutable)
println(x.serializingVars.map { "${it.key}=${it.value.value}"}) println(x.serializingVars.map { "${it.key}=${it.value.value}" })
} }
@Test @Test
fun memberValCantBeAssigned() = runTest { fun memberValCantBeAssigned() = runTest {
eval(""" eval(
"""
class Point(foo,bar) { class Point(foo,bar) {
val t = 42 val t = 42
var r = 142 var r = 142
@ -3867,23 +3899,46 @@ class ScriptTest {
// but r should be changed: // but r should be changed:
assertEqual(123, p.r) assertEqual(123, p.r)
""") """
)
} }
// @Test @Test
// fun testClassToJson() = runTest { fun testClassToJson() = runTest {
// val x = eval(""" eval(
// import lyng.serialization """
// class Point(foo,bar) { import lyng.serialization
// val t = 42 class Point(foo,bar) {
// } val t = 42
// val p = Point(1,2) }
// p.t = 121 // val is not serialized
// println(Point(10,"bar").toJsonString()) assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() )
// Lynon.encode(Point(1,2)) class Point2(foo,bar) {
// """.trimIndent()) var reason = 42
// println((x as ObjBitBuffer).bitArray.asUByteArray().toDump()) }
// // var is serialized instead
// } assertEquals( "{\"foo\":1,\"bar\":2,\"reason\":42}", Point2(1,2).toJsonString() )
""".trimIndent()
)
}
@Test
fun testCustomClassToJson() = runTest {
eval(
"""
import lyng.serialization
class Point2(foo,bar) {
var reason = 42
// but we override json serialization:
fun toJsonObject() {
{ "custom": true }
}
}
// var is serialized instead
assertEquals( "{\"custom\":true}", Point2(1,2).toJsonString() )
""".trimIndent()
)
}
} }