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

toJson, jsonTo, json (de)serialization for custom types; remove dependency on strtabs thanks to a hooking mechanism #14563

Merged
merged 14 commits into from
Jun 8, 2020
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@
users from the use of weak and insecure ciphers while still provides
adequate compatiblity with the majority of the Internet.

- new module `std/jsonutils` with hookable `jsonTo,toJson,fromJson` for json serialization/deserialization of custom types.

## Language changes
- In the newruntime it is now allowed to assign discriminator field without restrictions as long as case object doesn't have custom destructor. Discriminator value doesn't have to be a constant either. If you have custom destructor for case object and you do want to freely assign discriminator fields, it is recommended to refactor object into 2 objects like this:

Expand Down
42 changes: 12 additions & 30 deletions lib/pure/json.nim
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@
## var j2 = %* {"name": "Isaac", "books": ["Robot Dreams"]}
## j2["details"] = %* {"age":35, "pi":3.1415}
## echo j2
##
## See also: std/jsonutils for hookable json serialization/deserialization
## of arbitrary types.

runnableExamples:
## Note: for JObject, key ordering is preserved, unlike in some languages,
Expand All @@ -149,8 +152,10 @@ runnableExamples:
doAssert $(%* Foo()) == """{"a1":0,"a2":0,"a0":0,"a3":0,"a4":0}"""

import
hashes, tables, strtabs, strutils, lexbase, streams, macros, parsejson,
options
hashes, tables, strutils, lexbase, streams, macros, parsejson

import options # xxx remove this dependency using same approach as https://github.com/nim-lang/Nim/pull/14563
import std/private/since

export
tables.`$`
Expand Down Expand Up @@ -353,14 +358,6 @@ proc `[]=`*(obj: JsonNode, key: string, val: JsonNode) {.inline.} =
assert(obj.kind == JObject)
obj.fields[key] = val

proc `%`*(table: StringTableRef): JsonNode =
## Generic constructor for JSON data. Creates a new ``JObject JsonNode``.
result = newJObject()
result["mode"] = %($table.mode)
var data = newJObject()
for k, v in table: data[k] = %v
result["data"] = data

proc `%`*[T: object](o: T): JsonNode =
## Construct JsonNode from tuples and objects.
result = newJObject()
Expand All @@ -378,36 +375,36 @@ proc `%`*(o: enum): JsonNode =
## string. Creates a new ``JString JsonNode``.
result = %($o)

proc toJson(x: NimNode): NimNode {.compileTime.} =
proc toJsonImpl(x: NimNode): NimNode {.compileTime.} =
case x.kind
of nnkBracket: # array
if x.len == 0: return newCall(bindSym"newJArray")
result = newNimNode(nnkBracket)
for i in 0 ..< x.len:
result.add(toJson(x[i]))
result.add(toJsonImpl(x[i]))
result = newCall(bindSym("%", brOpen), result)
of nnkTableConstr: # object
if x.len == 0: return newCall(bindSym"newJObject")
result = newNimNode(nnkTableConstr)
for i in 0 ..< x.len:
x[i].expectKind nnkExprColonExpr
result.add newTree(nnkExprColonExpr, x[i][0], toJson(x[i][1]))
result.add newTree(nnkExprColonExpr, x[i][0], toJsonImpl(x[i][1]))
result = newCall(bindSym("%", brOpen), result)
of nnkCurly: # empty object
x.expectLen(0)
result = newCall(bindSym"newJObject")
of nnkNilLit:
result = newCall(bindSym"newJNull")
of nnkPar:
if x.len == 1: result = toJson(x[0])
if x.len == 1: result = toJsonImpl(x[0])
else: result = newCall(bindSym("%", brOpen), x)
else:
result = newCall(bindSym("%", brOpen), x)

macro `%*`*(x: untyped): untyped =
## Convert an expression to a JsonNode directly, without having to specify
## `%` for every element.
result = toJson(x)
result = toJsonImpl(x)

proc `==`*(a, b: JsonNode): bool =
## Check two nodes for equality
Expand Down Expand Up @@ -992,7 +989,6 @@ when defined(nimFixedForwardGeneric):
proc initFromJson[S,T](dst: var array[S,T]; jsonNode: JsonNode; jsonPath: var string)
proc initFromJson[T](dst: var Table[string,T]; jsonNode: JsonNode; jsonPath: var string)
proc initFromJson[T](dst: var OrderedTable[string,T]; jsonNode: JsonNode; jsonPath: var string)
proc initFromJson(dst: var StringTableRef; jsonNode: JsonNode; jsonPath: var string)
proc initFromJson[T](dst: var ref T; jsonNode: JsonNode; jsonPath: var string)
proc initFromJson[T](dst: var Option[T]; jsonNode: JsonNode; jsonPath: var string)
proc initFromJson[T: distinct](dst: var T; jsonNode: JsonNode; jsonPath: var string)
Expand Down Expand Up @@ -1073,20 +1069,6 @@ when defined(nimFixedForwardGeneric):
initFromJson(mgetOrPut(dst, key, default(T)), jsonNode[key], jsonPath)
jsonPath.setLen originalJsonPathLen

proc mgetOrPut(tab: var StringTableRef, key: string): var string =
if not tab.hasKey(key): tab[key] = ""
result = tab[key]

proc initFromJson(dst: var StringTableRef; jsonNode: JsonNode; jsonPath: var string) =
dst = newStringTable(parseEnum[StringTableMode](jsonNode["mode"].getStr))
verifyJsonKind(jsonNode, {JObject}, jsonPath)
let originalJsonPathLen = jsonPath.len
for key in keys(jsonNode["data"].fields):
jsonPath.add '.'
jsonPath.add key
initFromJson(mgetOrPut(dst, key), jsonNode[key], jsonPath)
jsonPath.setLen originalJsonPathLen

proc initFromJson[T](dst: var ref T; jsonNode: JsonNode; jsonPath: var string) =
verifyJsonKind(jsonNode, {JObject, JNull}, jsonPath)
if jsonNode.kind == JNull:
Expand Down
20 changes: 19 additions & 1 deletion lib/pure/strtabs.nim
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ type
StringTableObj* = object of RootObj
counter: int
data: KeyValuePairSeq
mode*: StringTableMode
mode: StringTableMode

StringTableRef* = ref StringTableObj

Expand Down Expand Up @@ -419,6 +419,24 @@ proc `%`*(f: string, t: StringTableRef, flags: set[FormatFlag] = {}): string {.
add(result, f[i])
inc(i)

since (1,3,5):
proc fromJsonHook*[T](a: var StringTableRef, b: T) =
## for json.fromJson
mixin jsonTo
var mode = jsonTo(b["mode"], StringTableMode)
a = newStringTable(mode)
let b2 = b["table"]
for k,v in b2: a[k] = jsonTo(v, string)

proc toJsonHook*[](a: StringTableRef): auto =
## for json.toJson
mixin newJObject
mixin toJson
result = newJObject()
result["mode"] = toJson($a.mode)
let t = newJObject()
for k,v in a: t[k] = toJson(v)
result["table"] = t

when isMainModule:
var x = {"k": "v", "11": "22", "565": "67"}.newStringTable
Expand Down
126 changes: 126 additions & 0 deletions lib/std/jsonutils.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
##[
This module implements a hookable (de)serialization for arbitrary types.
Design goal: avoid importing modules where a custom serialization is needed;
see strtabs.fromJsonHook,toJsonHook for an example.

]##

import std/[json,tables,strutils]

#[
xxx
use toJsonHook,fromJsonHook for Table|OrderedTable
add Options support also using toJsonHook,fromJsonHook and remove `json=>options` dependency

future direction:
add a way to customize serialization, for eg allowing missing
or extra fields in JsonNode, field renaming, and a way to handle cyclic references
using a cache of already visited addresses.
]#

proc isNamedTuple(T: typedesc): bool {.magic: "TypeTrait".}
proc distinctBase(T: typedesc): typedesc {.magic: "TypeTrait".}
template distinctBase[T](a: T): untyped = distinctBase(type(a))(a)

proc checkJsonImpl(cond: bool, condStr: string, msg = "") =
if not cond:
# just pick 1 exception type for simplicity; other choices would be:
# JsonError, JsonParser, JsonKindError
raise newException(ValueError, msg)

template checkJson(cond: untyped, msg = "") =
checkJsonImpl(cond, astToStr(cond), msg)

proc fromJson*[T](a: var T, b: JsonNode) =
## inplace version of `jsonTo`
#[
adding "json path" leading to `b` can be added in future work.
]#
checkJson b != nil, $($T, b)
when compiles(fromJsonHook(a, b)): fromJsonHook(a, b)
elif T is bool: a = to(b,T)
elif T is Table | OrderedTable:
a.clear
for k,v in b:
a[k] = jsonTo(v, typeof(a[k]))
elif T is enum:
case b.kind
of JInt: a = T(b.getBiggestInt())
of JString: a = parseEnum[T](b.getStr())
else: checkJson false, $($T, " ", b)
elif T is Ordinal: a = T(to(b, int))
elif T is pointer: a = cast[pointer](to(b, int))
elif T is distinct:
when nimvm:
# bug, potentially related to https://github.com/nim-lang/Nim/issues/12282
a = T(jsonTo(b, distinctBase(T)))
else:
a.distinctBase.fromJson(b)
elif T is string|SomeNumber: a = to(b,T)
elif T is JsonNode: a = b
elif T is ref | ptr:
if b.kind == JNull: a = nil
else:
a = T()
fromJson(a[], b)
elif T is array:
checkJson a.len == b.len, $(a.len, b.len, $T)
for i, val in b.getElems:
fromJson(a[i], val)
elif T is seq:
a.setLen b.len
for i, val in b.getElems:
fromJson(a[i], val)
elif T is object | tuple:
const isNamed = T is object or isNamedTuple(T)
when isNamed:
checkJson b.kind == JObject, $(b.kind) # we could customize whether to allow JNull
var num = 0
for key, val in fieldPairs(a):
num.inc
if b.hasKey key:
fromJson(val, b[key])
else:
# we could customize to allow this
checkJson false, $($T, key, b)
checkJson b.len == num, $(b.len, num, $T, b) # could customize
else:
checkJson b.kind == JArray, $(b.kind) # we could customize whether to allow JNull
var i = 0
for val in fields(a):
fromJson(val, b[i])
i.inc
else:
# checkJson not appropriate here
static: doAssert false, "not yet implemented: " & $T

proc jsonTo*(b: JsonNode, T: typedesc): T =
## reverse of `toJson`
fromJson(result, b)

proc toJson*[T](a: T): JsonNode =
## serializes `a` to json; uses `toJsonHook(a: T)` if it's in scope to
## customize serialization, see strtabs.toJsonHook for an example.
when compiles(toJsonHook(a)): result = toJsonHook(a)
elif T is Table | OrderedTable:
result = newJObject()
for k, v in pairs(a): result[k] = toJson(v)
elif T is object | tuple:
const isNamed = T is object or isNamedTuple(T)
when isNamed:
result = newJObject()
for k, v in a.fieldPairs: result[k] = toJson(v)
else:
result = newJArray()
for v in a.fields: result.add toJson(v)
elif T is ref | ptr:
if a == nil: result = newJNull()
else: result = toJson(a[])
elif T is array | seq:
result = newJArray()
for ai in a: result.add toJson(ai)
elif T is pointer: result = toJson(cast[int](a))
elif T is distinct: result = toJson(a.distinctBase)
elif T is bool: result = %(a)
elif T is Ordinal: result = %(a.ord)
else: result = %a
44 changes: 44 additions & 0 deletions tests/stdlib/tjsonutils.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
discard """
targets: "c cpp js"
"""

import std/jsonutils
import std/json

proc testRoundtrip[T](t: T, expected: string) =
let j = t.toJson
doAssert $j == expected, $j
doAssert j.jsonTo(T).toJson == j

import tables
import strtabs

template fn() =
block: # toJson, jsonTo
type Foo = distinct float
testRoundtrip('x', """120""")
when not defined(js):
testRoundtrip(cast[pointer](12345)): """12345"""

# causes workaround in `fromJson` potentially related to
# https://github.com/nim-lang/Nim/issues/12282
testRoundtrip(Foo(1.5)): """1.5"""

block:
testRoundtrip({"z": "Z", "y": "Y"}.toOrderedTable): """{"z":"Z","y":"Y"}"""
when not defined(js): # pending https://github.com/nim-lang/Nim/issues/14574
testRoundtrip({"z": (f1: 'f'), }.toTable): """{"z":{"f1":102}}"""

block:
testRoundtrip({"name": "John", "city": "Monaco"}.newStringTable): """{"mode":"modeCaseSensitive","table":{"city":"Monaco","name":"John"}}"""

block: # complex example
let t = {"z": "Z", "y": "Y"}.newStringTable
type A = ref object
a1: string
let a = (1.1, "fo", 'x', @[10,11], [true, false], [t,newStringTable()], [0'i8,3'i8], -4'i16, (foo: 0.5'f32, bar: A(a1: "abc"), bar2: A.default))
testRoundtrip(a):
"""[1.1,"fo",120,[10,11],[true,false],[{"mode":"modeCaseSensitive","table":{"y":"Y","z":"Z"}},{"mode":"modeCaseSensitive","table":{}}],[0,3],-4,{"foo":0.5,"bar":{"a1":"abc"},"bar2":null}]"""

static: fn()
fn()