From 8a4ed98bbd0a9479df15af2fa31da38a586ea6d5 Mon Sep 17 00:00:00 2001 From: andri lim Date: Sat, 27 Jul 2024 07:27:11 +0700 Subject: [PATCH] Add compile time switch to alter encoder enum representation (#95) * Add compile time switch to alter encoder enum representation * Fix test * Add enum section in README.md Add more tests and encoder features of enums * Add enums section to README.md --- README.md | 59 +++++++++++++++++++ json_serialization/format.nim | 27 +++++++++ json_serialization/writer.nim | 39 ++++++++++++- tests/test_json_flavor.nim | 68 +++++++++++++++++++++ tests/test_writer.nim | 107 +++++++++++++++++++++++++++++++++- 5 files changed, 296 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8fd47f3..6a8e4e9 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,65 @@ writeNumber[F,T](w: var JsonWriter[F], value: JsonNumber[T]) writeJsonValueRef[F,T](w: var JsonWriter[F], value: JsonValueRef[T]) ``` +## Enums + +```Nim +type + Fruit = enum + Apple = "Apple" + Banana = "Banana" + + Drawer = enum + One + Two + + Number = enum + Three = 3 + Four = 4 + + Mixed = enum + Six = 6 + Seven = "Seven" +``` + +nim-json-serialization automatically detect which representation an enum should be parsed. +The detection occurs when parse json literal and from the enum declaration itself. +'Fruit' expect string literal. 'Drawer' or 'Number' expect numeric literal. +'Mixed' is disallowed. If the json literal does not match the expected enum style, +exception will be raised. But you can configure individual enum type with: + +```Nim +configureJsonDeserialization( + T: type[enum], allowNumericRepr: static[bool] = false, + stringNormalizer: static[proc(s: string): string] = strictNormalize) + +# example: +Mixed.configureJsonDeserialization(allowNumericRepr = true) # only at top level +``` + +When encode an enum, user is also given flexibility to configure at Flavor level +or for individual enum type. + +```Nim +type + EnumRepresentation* = enum + EnumAsString + EnumAsNumber + EnumAsStringifiedNumber + +# examples: + +# Flavor level +Json.flavorEnumRep(EnumAsString) # default flavor, can be called from non top level +Flavor.flavorEnumRep(EnumAsNumber) # custom flavor, can be called from non top level + +# individual enum type no matter what flavor +Fruit.configureJsonSerialization(EnumAsNumber) # only at top level + +# individual enum type of specific flavor +MyJson.flavorEnumRep(Drawer, EnumAsString) # only at top level +``` + ## License Licensed and distributed under either of diff --git a/json_serialization/format.nim b/json_serialization/format.nim index 2f431c4..256e85b 100644 --- a/json_serialization/format.nim +++ b/json_serialization/format.nim @@ -20,12 +20,31 @@ template supports*(_: type Json, T: type): bool = # The JSON format should support every type true +type + EnumRepresentation* = enum + EnumAsString + EnumAsNumber + EnumAsStringifiedNumber + template flavorUsesAutomaticObjectSerialization*(T: type DefaultFlavor): bool = true template flavorOmitsOptionalFields*(T: type DefaultFlavor): bool = true template flavorRequiresAllFields*(T: type DefaultFlavor): bool = false template flavorAllowsUnknownFields*(T: type DefaultFlavor): bool = false template flavorSkipNullFields*(T: type DefaultFlavor): bool = false +var DefaultFlavorEnumRep {.compileTime.} = EnumAsString +template flavorEnumRep*(T: type DefaultFlavor): EnumRepresentation = + DefaultFlavorEnumRep + +template flavorEnumRep*(T: type DefaultFlavor, rep: static[EnumRepresentation]) = + static: + DefaultFlavorEnumRep = rep + +# If user choose to use `Json` instead of `DefaultFlavor`, it still goes to `DefaultFlavor` +template flavorEnumRep*(T: type Json, rep: static[EnumRepresentation]) = + static: + DefaultFlavorEnumRep = rep + # We create overloads of these traits to force the mixin treatment of the symbols type DummyFlavor* = object template flavorUsesAutomaticObjectSerialization*(T: type DummyFlavor): bool = true @@ -53,3 +72,11 @@ template createJsonFlavor*(FlavorName: untyped, template flavorRequiresAllFields*(T: type FlavorName): bool = requireAllFields template flavorAllowsUnknownFields*(T: type FlavorName): bool = allowUnknownFields template flavorSkipNullFields*(T: type FlavorName): bool = skipNullFields + + var `FlavorName EnumRep` {.compileTime.} = EnumAsString + template flavorEnumRep*(T: type FlavorName): EnumRepresentation = + `FlavorName EnumRep` + + template flavorEnumRep*(T: type FlavorName, rep: static[EnumRepresentation]) = + static: + `FlavorName EnumRep` = rep diff --git a/json_serialization/writer.nim b/json_serialization/writer.nim index 4162aa1..4606287 100644 --- a/json_serialization/writer.nim +++ b/json_serialization/writer.nim @@ -271,6 +271,24 @@ proc writeJsonValueRef*[F,T](w: var JsonWriter[F], value: JsonValueRef[T]) = of JsonValueKind.Null: append "null" +template writeEnumImpl(w: var JsonWriter, value, enumRep) = + mixin writeValue + when enumRep == EnumAsString: + w.writeValue $value + elif enumRep == EnumAsNumber: + w.stream.writeText(value.int) + elif enumRep == EnumAsStringifiedNumber: + w.writeValue $value.int + +template writeValue*(w: var JsonWriter, value: enum) = + # We extract this as a template because + # if we put it into `proc writeValue` below + # the Nim compiler generic cache mechanism + # will mess up with the compile time + # conditional selection + type Flavor = type(w).Flavor + writeEnumImpl(w, value, Flavor.flavorEnumRep()) + proc writeValue*(w: var JsonWriter, value: auto) {.gcsafe, raises: [IOError].} = mixin writeValue @@ -333,9 +351,6 @@ proc writeValue*(w: var JsonWriter, value: auto) {.gcsafe, raises: [IOError].} = elif value is bool: append if value: "true" else: "false" - elif value is enum: - w.writeValue $value - elif value is range: when low(typeof(value)) < 0: w.stream.writeText int64(value) @@ -384,3 +399,21 @@ proc toJson*(v: auto, pretty = false, typeAnnotations = false): string = template serializesAsTextInJson*(T: type[enum]) = template writeValue*(w: var JsonWriter, val: T) = w.writeValue $val + +template configureJsonSerialization*( + T: type[enum], enumRep: static[EnumRepresentation]) = + proc writeValue*(w: var JsonWriter, + value: T) {.gcsafe, raises: [IOError].} = + writeEnumImpl(w, value, enumRep) + +template configureJsonSerialization*(Flavor: type, + T: type[enum], + enumRep: static[EnumRepresentation]) = + when Flavor is Json: + proc writeValue*(w: var JsonWriter[DefaultFlavor], + value: T) {.gcsafe, raises: [IOError].} = + writeEnumImpl(w, value, enumRep) + else: + proc writeValue*(w: var JsonWriter[Flavor], + value: T) {.gcsafe, raises: [IOError].} = + writeEnumImpl(w, value, enumRep) diff --git a/tests/test_json_flavor.nim b/tests/test_json_flavor.nim index 4ac2982..0d25d80 100644 --- a/tests/test_json_flavor.nim +++ b/tests/test_json_flavor.nim @@ -132,3 +132,71 @@ suite "Test JsonFlavor": # field should not processed at all let y = NullyFields.decode(jsonTextWithNullFields, ListOnly) check y.list.string.len == 0 + + test "Enum value representation primitives": + when NullyFields.flavorEnumRep() == EnumAsString: + check true + elif NullyFields.flavorEnumRep() == EnumAsNumber: + check false + elif NullyFields.flavorEnumRep() == EnumAsStringifiedNumber: + check false + + NullyFields.flavorEnumRep(EnumAsNumber) + when NullyFields.flavorEnumRep() == EnumAsString: + check false + elif NullyFields.flavorEnumRep() == EnumAsNumber: + check true + elif NullyFields.flavorEnumRep() == EnumAsStringifiedNumber: + check false + + NullyFields.flavorEnumRep(EnumAsStringifiedNumber) + when NullyFields.flavorEnumRep() == EnumAsString: + check false + elif NullyFields.flavorEnumRep() == EnumAsNumber: + check false + elif NullyFields.flavorEnumRep() == EnumAsStringifiedNumber: + check true + + test "Enum value representation of custom flavor": + type + ExoticFruits = enum + DragonFruit + SnakeFruit + StarFruit + + NullyFields.flavorEnumRep(EnumAsNumber) + let u = NullyFields.encode(DragonFruit) + check u == "0" + + NullyFields.flavorEnumRep(EnumAsString) + let v = NullyFields.encode(SnakeFruit) + check v == "\"SnakeFruit\"" + + NullyFields.flavorEnumRep(EnumAsStringifiedNumber) + let w = NullyFields.encode(StarFruit) + check w == "\"2\"" + + test "EnumAsString of custom flavor": + type + Fruit = enum + Banana = "BaNaNa" + Apple = "ApplE" + JackFruit = "VVV" + + NullyFields.flavorEnumRep(EnumAsString) + let u = NullyFields.encode(Banana) + check u == "\"BaNaNa\"" + + let v = NullyFields.encode(Apple) + check v == "\"ApplE\"" + + let w = NullyFields.encode(JackFruit) + check w == "\"VVV\"" + + NullyFields.flavorEnumRep(EnumAsStringifiedNumber) + let x = NullyFields.encode(JackFruit) + check x == "\"2\"" + + NullyFields.flavorEnumRep(EnumAsNumber) + let z = NullyFields.encode(Banana) + check z == "0" \ No newline at end of file diff --git a/tests/test_writer.nim b/tests/test_writer.nim index 9a98e2e..3daad24 100644 --- a/tests/test_writer.nim +++ b/tests/test_writer.nim @@ -23,7 +23,7 @@ type a: Opt[int] b: Option[string] c: int - + createJsonFlavor YourJson, omitOptionalFields = false @@ -33,6 +33,20 @@ createJsonFlavor MyJson, ObjectWithOptionalFields.useDefaultSerializationIn YourJson ObjectWithOptionalFields.useDefaultSerializationIn MyJson +type + FruitX = enum + BananaX = "BaNaNa" + AppleX = "ApplE" + GrapeX = "VVV" + + Drawer = enum + One + Two + +FruitX.configureJsonSerialization(EnumAsString) +Json.configureJsonSerialization(Drawer, EnumAsNumber) +MyJson.configureJsonSerialization(Drawer, EnumAsString) + proc writeValue*(w: var JsonWriter, val: OWOF) {.gcsafe, raises: [IOError].} = w.writeObject(OWOF): @@ -168,3 +182,94 @@ suite "Test writer": check uu.string == """{"a":123,"b":"nano","c":456}""" let vv = YourJson.encode(y) check vv.string == """{"a":null,"b":null,"c":999}""" + + test "Enum value representation primitives": + when DefaultFlavor.flavorEnumRep() == EnumAsString: + check true + elif DefaultFlavor.flavorEnumRep() == EnumAsNumber: + check false + elif DefaultFlavor.flavorEnumRep() == EnumAsStringifiedNumber: + check false + + DefaultFlavor.flavorEnumRep(EnumAsNumber) + when DefaultFlavor.flavorEnumRep() == EnumAsString: + check false + elif DefaultFlavor.flavorEnumRep() == EnumAsNumber: + check true + elif DefaultFlavor.flavorEnumRep() == EnumAsStringifiedNumber: + check false + + DefaultFlavor.flavorEnumRep(EnumAsStringifiedNumber) + when DefaultFlavor.flavorEnumRep() == EnumAsString: + check false + elif DefaultFlavor.flavorEnumRep() == EnumAsNumber: + check false + elif DefaultFlavor.flavorEnumRep() == EnumAsStringifiedNumber: + check true + + test "Enum value representation of DefaultFlavor": + type + ExoticFruits = enum + DragonFruit + SnakeFruit + StarFruit + + DefaultFlavor.flavorEnumRep(EnumAsNumber) + let u = Json.encode(DragonFruit) + check u == "0" + + DefaultFlavor.flavorEnumRep(EnumAsString) + let v = Json.encode(SnakeFruit) + check v == "\"SnakeFruit\"" + + DefaultFlavor.flavorEnumRep(EnumAsStringifiedNumber) + let w = Json.encode(StarFruit) + check w == "\"2\"" + + test "EnumAsString of DefaultFlavor/Json": + type + Fruit = enum + Banana = "BaNaNa" + Apple = "ApplE" + JackFruit = "VVV" + + ObjectWithEnumField = object + fruit: Fruit + + Json.flavorEnumRep(EnumAsString) + let u = Json.encode(Banana) + check u == "\"BaNaNa\"" + + let v = Json.encode(Apple) + check v == "\"ApplE\"" + + let w = Json.encode(JackFruit) + check w == "\"VVV\"" + + Json.flavorEnumRep(EnumAsStringifiedNumber) + let x = Json.encode(JackFruit) + check x == "\"2\"" + + Json.flavorEnumRep(EnumAsNumber) + let z = Json.encode(Banana) + check z == "0" + + let obj = ObjectWithEnumField(fruit: Banana) + let zz = Json.encode(obj) + check zz == """{"fruit":0}""" + + test "Individual enum configuration": + Json.flavorEnumRep(EnumAsNumber) + # Although the flavor config is EnumAsNumber + # FruitX is configured as EnumAsAstring + let z = Json.encode(BananaX) + check z == "\"BaNaNa\"" + + # configuration: Json.configureJsonSerialization(Drawer, EnumAsNumber) + let u = Json.encode(Two) + check u == "1" + + # configuration: MyJson.configureJsonSerialization(Drawer, EnumAsString) + let v = MyJson.encode(One) + check v == "\"One\"" + \ No newline at end of file