From ed4440d881f9e2cb7778c01a0f638d928f339aa7 Mon Sep 17 00:00:00 2001 From: Etan Kissling Date: Sat, 27 May 2023 11:42:08 +0200 Subject: [PATCH] use string value when encoding enums (#55) Currently, we encode enum values always as the numeric value `ord(val)`. unless explicitly overridden using `serializesAsTextInJson` or with a custom `writeValue` implementation. Reduce verbosity by automatically doing that. --- json_serialization/reader.nim | 74 +++++++++---- json_serialization/writer.nim | 3 +- tests/test_serialization.nim | 197 ++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 20 deletions(-) diff --git a/json_serialization/reader.nim b/json_serialization/reader.nim index 6311f84..9c5cea8 100644 --- a/json_serialization/reader.nim +++ b/json_serialization/reader.nim @@ -1,14 +1,15 @@ {.experimental: "notnil".} import - std/[tables, strutils, typetraits, macros, strformat], + std/[tables, macros, strformat], + stew/[enums, objects], stew/shims/[enumutils, typetraits], faststreams/inputs, serialization/[formats, object_serialization, errors], "."/[format, types, lexer] from json import JsonNode, JsonNodeKind export - inputs, format, types, errors + enumutils, inputs, format, types, errors type JsonReader*[Flavor = DefaultFlavor] = object @@ -27,7 +28,8 @@ type etValue = "value" etBool = "bool literal" etInt = "integer" - etEnum = "enum value (int or string)" + etEnumAny = "enum value (int / string)" + etEnumString = "enum value (string)" etNumber = "number" etString = "string" etComma = "comma" @@ -152,9 +154,6 @@ proc init*(T: type JsonReader, result.lexer = JsonLexer.init(stream, mode) result.lexer.next() -proc setParsed[T: enum](e: var T, s: string) = - e = parseEnum[T](s) - proc requireToken*(r: var JsonReader, tk: TokKind) = if r.lexer.tok != tk: r.raiseUnexpectedToken case tk @@ -449,6 +448,49 @@ func isBitwiseSubsetOf[N](lhs, rhs: array[N, uint]): bool = template isCharArray[N](v: array[N, char]): bool = true template isCharArray(v: auto): bool = false +func parseStringEnum[T]( + r: var JsonReader, value: var T, + stringNormalizer: static[proc(s: string): string]) = + try: + value = genEnumCaseStmt( + T, r.lexer.strVal, + default = nil, ord(T.low), ord(T.high), stringNormalizer) + except ValueError as err: + const typeName = typetraits.name(T) + r.raiseUnexpectedValue("Invalid value for '" & typeName & "'") + +func strictNormalize(s: string): string = # Match enum value exactly + s + +proc parseEnum[T]( + r: var JsonReader, value: var T, allowNumericRepr: static[bool] = false, + stringNormalizer: static[proc(s: string): string] = strictNormalize) = + const style = T.enumStyle + let tok = r.lexer.tok + case tok + of tkString: + r.parseStringEnum(value, stringNormalizer) + of tkInt: + when allowNumericRepr: + case style + of EnumStyle.Numeric: + if not value.checkedEnumAssign(r.lexer.absIntVal): + const typeName = typetraits.name(T) + r.raiseUnexpectedValue("Out of range for '" & typeName & "'") + of EnumStyle.AssociatedStrings: + r.raiseUnexpectedToken etEnumString + else: + r.raiseUnexpectedToken etEnumString + else: + case style + of EnumStyle.Numeric: + when allowNumericRepr: + r.raiseUnexpectedToken etEnumAny + else: + r.raiseUnexpectedToken etEnumString + of EnumStyle.AssociatedStrings: + r.raiseUnexpectedToken etEnumString + proc readValue*[T](r: var JsonReader, value: var T) {.gcsafe, raises: [SerializationError, IOError, Defect].} = ## Master filed/object parser. This function relies on customised sub-mixins for particular @@ -540,18 +582,7 @@ proc readValue*[T](r: var JsonReader, value: var T) value[] = readValue(r, type(value[])) elif value is enum: - case tok - of tkString: - try: - value.setParsed(r.lexer.strVal) - except ValueError as err: - const typeName = typetraits.name(T) - r.raiseUnexpectedValue("Expected valid '" & typeName & "' value") - of tkInt: - # TODO: validate that the value is in range - value = type(value)(r.lexer.absIntVal) - else: - r.raiseUnexpectedToken etEnum + r.parseEnum(value) r.lexer.next() elif value is SomeInteger: @@ -673,3 +704,10 @@ iterator readObjectFields*(r: var JsonReader): string = for key in readObjectFields(r, string): yield key +template configureJsonDeserialization*( + T: type[enum], allowNumericRepr: static[bool] = false, + stringNormalizer: static[proc(s: string): string] = strictNormalize) = + proc readValue*(r: var JsonReader, value: var T) {. + raises: [Defect, IOError, SerializationError].} = + static: doAssert not allowNumericRepr or enumStyle(T) == EnumStyle.Numeric + r.parseEnum(value, allowNumericRepr, stringNormalizer) diff --git a/json_serialization/writer.nim b/json_serialization/writer.nim index dabb89c..4b57518 100644 --- a/json_serialization/writer.nim +++ b/json_serialization/writer.nim @@ -215,7 +215,7 @@ proc writeValue*(w: var JsonWriter, value: auto) = append if value: "true" else: "false" elif value is enum: - w.stream.writeText ord(value) + w.writeValue $value elif value is range: when low(typeof(value)) < 0: @@ -258,4 +258,3 @@ proc toJson*(v: auto, pretty = false, typeAnnotations = false): string = template serializesAsTextInJson*(T: type[enum]) = template writeValue*(w: var JsonWriter, val: T) = w.writeValue $val - diff --git a/tests/test_serialization.nim b/tests/test_serialization.nim index 094186e..d4d086e 100644 --- a/tests/test_serialization.nim +++ b/tests/test_serialization.nim @@ -189,6 +189,68 @@ var invalid = Invalid(distance: Mile(100)) when false: reject invalid.toJson else: discard invalid +when (NimMajor, NimMinor) < (1, 4): # Copy from `std/strutils` + # + # + # Nim's Runtime Library + # (c) Copyright 2012 Andreas Rumpf + # + # See the file "copying.txt", included in this + # distribution, for details about the copyright. + # + func nimIdentNormalize*(s: string): string = + ## Normalizes the string `s` as a Nim identifier. + ## + ## That means to convert to lower case and remove any '_' on all characters + ## except first one. + runnableExamples: + doAssert nimIdentNormalize("Foo_bar") == "Foobar" + result = newString(s.len) + if s.len > 0: + result[0] = s[0] + var j = 1 + for i in 1..len(s) - 1: + if s[i] in {'A'..'Z'}: + result[j] = chr(ord(s[i]) + (ord('a') - ord('A'))) + inc j + elif s[i] != '_': + result[j] = s[i] + inc j + if j != s.len: setLen(result, j) + +type EnumTestX = enum + x0, + x1, + x2 + +type EnumTestY = enum + y1 = 1, + y3 = 3, + y4, + y6 = 6 +EnumTestY.configureJsonDeserialization( + allowNumericRepr = true) + +type EnumTestZ = enum + z1 = "aaa", + z2 = "bbb", + z3 = "ccc" + +type EnumTestN = enum + n1 = "aaa", + n2 = "bbb", + n3 = "ccc" +EnumTestN.configureJsonDeserialization( + stringNormalizer = nimIdentNormalize) + +type EnumTestO = enum + o1, + o2, + o3 +EnumTestO.configureJsonDeserialization( + allowNumericRepr = true, + stringNormalizer = nimIdentNormalize) + suite "toJson tests": test "encode primitives": check: @@ -196,6 +258,141 @@ suite "toJson tests": "".toJson == "\"\"" "abc".toJson == "\"abc\"" + test "enums": + Json.roundtripTest x0, "\"x0\"" + Json.roundtripTest x1, "\"x1\"" + Json.roundtripTest x2, "\"x2\"" + expect UnexpectedTokenError: + discard Json.decode("0", EnumTestX) + expect UnexpectedTokenError: + discard Json.decode("1", EnumTestX) + expect UnexpectedTokenError: + discard Json.decode("2", EnumTestX) + expect UnexpectedTokenError: + discard Json.decode("3", EnumTestX) + expect UnexpectedValueError: + discard Json.decode("\"X0\"", EnumTestX) + expect UnexpectedValueError: + discard Json.decode("\"X1\"", EnumTestX) + expect UnexpectedValueError: + discard Json.decode("\"X2\"", EnumTestX) + expect UnexpectedValueError: + discard Json.decode("\"x_0\"", EnumTestX) + expect UnexpectedValueError: + discard Json.decode("\"\"", EnumTestX) + expect UnexpectedValueError: + discard Json.decode("\"0\"", EnumTestX) + + Json.roundtripTest y1, "\"y1\"" + Json.roundtripTest y3, "\"y3\"" + Json.roundtripTest y4, "\"y4\"" + Json.roundtripTest y6, "\"y6\"" + check: + Json.decode("1", EnumTestY) == y1 + Json.decode("3", EnumTestY) == y3 + Json.decode("4", EnumTestY) == y4 + Json.decode("6", EnumTestY) == y6 + expect UnexpectedValueError: + discard Json.decode("0", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("2", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("5", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("7", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("\"Y1\"", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("\"Y3\"", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("\"Y4\"", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("\"Y6\"", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("\"y_1\"", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("\"\"", EnumTestY) + expect UnexpectedValueError: + discard Json.decode("\"1\"", EnumTestY) + + Json.roundtripTest z1, "\"aaa\"" + Json.roundtripTest z2, "\"bbb\"" + Json.roundtripTest z3, "\"ccc\"" + expect UnexpectedTokenError: + discard Json.decode("0", EnumTestZ) + expect UnexpectedValueError: + discard Json.decode("\"AAA\"", EnumTestZ) + expect UnexpectedValueError: + discard Json.decode("\"BBB\"", EnumTestZ) + expect UnexpectedValueError: + discard Json.decode("\"CCC\"", EnumTestZ) + expect UnexpectedValueError: + discard Json.decode("\"z1\"", EnumTestZ) + expect UnexpectedValueError: + discard Json.decode("\"a_a_a\"", EnumTestZ) + expect UnexpectedValueError: + discard Json.decode("\"\"", EnumTestZ) + expect UnexpectedValueError: + discard Json.decode("\"\ud83d\udc3c\"", EnumTestZ) + + Json.roundtripTest n1, "\"aaa\"" + Json.roundtripTest n2, "\"bbb\"" + Json.roundtripTest n3, "\"ccc\"" + check: + Json.decode("\"aAA\"", EnumTestN) == n1 + Json.decode("\"bBB\"", EnumTestN) == n2 + Json.decode("\"cCC\"", EnumTestN) == n3 + Json.decode("\"a_a_a\"", EnumTestN) == n1 + Json.decode("\"b_b_b\"", EnumTestN) == n2 + Json.decode("\"c_c_c\"", EnumTestN) == n3 + expect UnexpectedTokenError: + discard Json.decode("0", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"AAA\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"BBB\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"CCC\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"Aaa\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"Bbb\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"Ccc\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"n1\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"_aaa\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"\"", EnumTestN) + expect UnexpectedValueError: + discard Json.decode("\"\ud83d\udc3c\"", EnumTestN) + + Json.roundtripTest o1, "\"o1\"" + Json.roundtripTest o2, "\"o2\"" + Json.roundtripTest o3, "\"o3\"" + check: + Json.decode("\"o_1\"", EnumTestO) == o1 + Json.decode("\"o_2\"", EnumTestO) == o2 + Json.decode("\"o_3\"", EnumTestO) == o3 + Json.decode("0", EnumTestO) == o1 + Json.decode("1", EnumTestO) == o2 + Json.decode("2", EnumTestO) == o3 + expect UnexpectedValueError: + discard Json.decode("3", EnumTestO) + expect UnexpectedValueError: + discard Json.decode("\"O1\"", EnumTestO) + expect UnexpectedValueError: + discard Json.decode("\"O2\"", EnumTestO) + expect UnexpectedValueError: + discard Json.decode("\"O3\"", EnumTestO) + expect UnexpectedValueError: + discard Json.decode("\"_o1\"", EnumTestO) + expect UnexpectedValueError: + discard Json.decode("\"\"", EnumTestO) + expect UnexpectedValueError: + discard Json.decode("\"\ud83d\udc3c\"", EnumTestO) + test "simple objects": var s = Simple(x: 10, y: "test", distance: Meter(20))