From 84f2f8fac4057463ea4589fcc0fe5aae7b7cbca7 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 4 Dec 2025 23:58:31 +0100 Subject: [PATCH] fix #75 simple classes JSON serialization with custom formats --- docs/json_and_kotlin_serialization.md | 64 ++++++- .../net/sergeych/lyng/obj/ObjInstance.kt | 7 +- lynglib/src/commonTest/kotlin/ScriptTest.kt | 165 ++++++++++++------ 3 files changed, 173 insertions(+), 63 deletions(-) diff --git a/docs/json_and_kotlin_serialization.md b/docs/json_and_kotlin_serialization.md index 9809979..d7bdc71 100644 --- a/docs/json_and_kotlin_serialization.md +++ b/docs/json_and_kotlin_serialization.md @@ -1,17 +1,69 @@ # 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. - -## Serializae to kotlin string +## Serialization in Lyng // in lyng assertEquals("{\"a\":1}", {a: 1}.toJsonString()) 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 /** @@ -38,6 +90,6 @@ suspend inline fun Obj.decodeSerializable(scope: Scope= Scope()) = decodeSerializableWith(serializer(), 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. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt index 7db9e99..8e6916f 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt @@ -232,8 +232,11 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { ?: scope.raiseError("can't serialize non-serializable object (no constructor meta)") for (entry in meta.params) result[entry.name] = readField(scope, entry.name).value.toJson(scope) - for (i in serializingVars) - result[i.key] = i.value.value.toJson(scope) + for (i in serializingVars) { + // 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) } diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 14c5472..757e58e 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -928,10 +928,10 @@ class ScriptTest { assertEquals(7, c.eval("x++").toInt()) assertEquals( 8, c.eval("x") - .also { - println("${it.toDouble()} ${it.toInt()} ${it.toLong()} ${it.toInt()}") - } - .toInt()) + .also { + println("${it.toDouble()} ${it.toInt()} ${it.toLong()} ${it.toInt()}") + } + .toInt()) } @Test @@ -1878,7 +1878,7 @@ class ScriptTest { @Test fun testIntExponentRealForm() = runTest { - when(val x = eval("1e-6").toString()) { + when (val x = eval("1e-6").toString()) { "0.000001", "1E-6", "1e-6" -> {} else -> fail("Excepted 1e-6 got $x") } @@ -2577,14 +2577,16 @@ class ScriptTest { @Test fun testSetAddRemoveSet() = runTest { - eval(""" + eval( + """ val s1 = Set( 1, 2 3) val s2 = Set( 3, 4 5) assertEquals( Set(1,2,3,4,5), s1 + s2 ) assertEquals( Set(1,2,3,4,5), s1 + s2.toList() ) assertEquals( Set(1,2), s1 - s2 ) assertEquals( Set(1,2), s1 - s2.toList() ) - """.trimIndent()) + """.trimIndent() + ) } @Test @@ -2651,7 +2653,7 @@ class ScriptTest { ) } - class ObjTestFoo(val value: ObjString): Obj() { + class ObjTestFoo(val value: ObjString) : Obj() { override val objClass: ObjClass = klass @@ -2669,13 +2671,15 @@ class ScriptTest { fun TestApplyFromKotlin() = runTest { val scope = Script.newScope() scope.addConst("testfoo", ObjTestFoo(ObjString("bar2"))) - scope.eval(""" + scope.eval( + """ assertEquals(testfoo.test(), "bar2") testfoo.apply { println(test()) assertEquals(test(), "bar2") } - """.trimIndent()) + """.trimIndent() + ) } @Test @@ -2683,7 +2687,8 @@ class ScriptTest { withContext(Dispatchers.Default) { withTimeout(1.seconds) { val s = Script.newScope() - s.eval(""" + s.eval( + """ fun dosomething() { var x = 0 for( i in 1..100) { @@ -2692,7 +2697,8 @@ class ScriptTest { delay(100) assert(x == 5050) } - """.trimIndent()) + """.trimIndent() + ) (0..100).map { globalDefer { s.eval("dosomething()") @@ -2707,7 +2713,8 @@ class ScriptTest { withContext(Dispatchers.Default) { withTimeout(1.seconds) { val s = Script.newScope() - s.eval(""" + s.eval( + """ // it is intentionally not optimal to provoke // RC errors: class AtomicCounter { @@ -2742,7 +2749,8 @@ class ScriptTest { } assertEquals( 100, ac.getCounter() ) - """.trimIndent()) + """.trimIndent() + ) } } } @@ -3580,7 +3588,7 @@ class ScriptTest { } -// @Test + // @Test fun testMinimumOptimization() = runTest { for (i in 1..200) { bm { @@ -3627,16 +3635,24 @@ class ScriptTest { val scope1 = Script.newScope() // extension foo should be local to scope1 - assertEquals("a_foo", scope1.eval(""" + assertEquals( + "a_foo", scope1.eval( + """ fun String.foo() { this + "_foo" } "a".foo() - """.trimIndent()).toString()) + """.trimIndent() + ).toString() + ) val scope2 = Script.newScope() - assertEquals("a_bar", scope2.eval(""" + assertEquals( + "a_bar", scope2.eval( + """ fun String.foo() { this + "_bar" } "a".foo() - """.trimIndent()).toString()) + """.trimIndent() + ).toString() + ) } @Test @@ -3658,15 +3674,18 @@ class ScriptTest { @Test fun testRangeIsIterable() = runTest { - eval(""" + eval( + """ val r = 1..10 assert( r is Iterable ) - """.trimIndent()) + """.trimIndent() + ) } @Test fun testCallAndResultOrder() = runTest { - eval(""" + eval( + """ import lyng.stdlib fun test(a="a", b="b", c="c") { [a, b, c] } @@ -3681,13 +3700,15 @@ class ScriptTest { // the parentheses here are in fact unnecessary: val ok2 = test { void }.last() assert( ok2 is Callable) - """.trimIndent()) + """.trimIndent() + ) } @Test fun testIterableMinMax() = runTest { - eval(""" + eval( + """ import lyng.stdlib assertEquals( -100, (1..100).toList().minOf { -it } ) assertEquals( -1, (1..100).toList().maxOf { -it } ) @@ -3764,29 +3785,34 @@ class ScriptTest { @Test fun testInlineArrayLiteral() = runTest { - eval(""" + eval( + """ val res = [] for( i in [4,3,1] ) { res.add(i) } assertEquals( [4,3,1], res ) - """.trimIndent()) + """.trimIndent() + ) } @Test fun testInlineMapLiteral() = runTest { - eval(""" + eval( + """ val res = {} for( i in {foo: "bar"} ) { res[i.key] = i.value } assertEquals( {foo: "bar"}, res ) - """.trimIndent()) + """.trimIndent() + ) } @Test fun testCommentsInClassConstructor() = runTest { - eval(""" + eval( + """ class T( // comment 1 val x: Int, @@ -3801,7 +3827,7 @@ class ScriptTest { } @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 fun testToJson() = runTest { @@ -3810,26 +3836,30 @@ class ScriptTest { assertEquals(x.toJson().toString(), """{"foo":"bar","one":1,"ok":true}""") assertEquals( (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()) assertEquals(JSTest1("bar", 1, true), x.decodeSerializable()) } @Test fun testInstanceVars() = runTest { - var x = eval(""" + var x = eval( + """ // in this case, x and y are constructor parameters, not instance variables: class T(x,y) { // and z is val and therefore needn't serialization either: val z = x + y } T(1, 2) - """.trimIndent()) as ObjInstance - println(x.serializingVars.map { "${it.key}=${it.value.value}"}) + """.trimIndent() + ) as ObjInstance + println(x.serializingVars.map { "${it.key}=${it.value.value}" }) // so serializingVars is empty: assertEquals(emptyMap(), x.serializingVars) - x = eval(""" + x = eval( + """ class T(x,y) { // variable z though should be serialized: var z = x + y @@ -3838,17 +3868,19 @@ class ScriptTest { x.z = 100 assertEquals(100, x.z) x - """.trimIndent()) as ObjInstance + """.trimIndent() + ) as ObjInstance // z is instance var, it must present val z = x.serializingVars["z"] ?: x.serializingVars["T::z"] // and be mutable: - assertTrue( z!!.isMutable ) - println(x.serializingVars.map { "${it.key}=${it.value.value}"}) + assertTrue(z!!.isMutable) + println(x.serializingVars.map { "${it.key}=${it.value.value}" }) } @Test fun memberValCantBeAssigned() = runTest { - eval(""" + eval( + """ class Point(foo,bar) { val t = 42 var r = 142 @@ -3867,23 +3899,46 @@ class ScriptTest { // but r should be changed: assertEqual(123, p.r) - """) + """ + ) } -// @Test -// fun testClassToJson() = runTest { -// val x = eval(""" -// import lyng.serialization -// class Point(foo,bar) { -// val t = 42 -// } -// val p = Point(1,2) -// p.t = 121 -// println(Point(10,"bar").toJsonString()) -// Lynon.encode(Point(1,2)) -// """.trimIndent()) -// println((x as ObjBitBuffer).bitArray.asUByteArray().toDump()) -// -// } + @Test + fun testClassToJson() = runTest { + eval( + """ + import lyng.serialization + class Point(foo,bar) { + val t = 42 + } + // val is not serialized + assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() ) + class Point2(foo,bar) { + var reason = 42 + } + // 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() + ) + } }