Merge: json::serialization: don't raise errors on missing attributes with default...
authorJean Privat <jean@pryen.org>
Thu, 1 Sep 2016 01:53:15 +0000 (21:53 -0400)
committerJean Privat <jean@pryen.org>
Thu, 1 Sep 2016 01:53:15 +0000 (21:53 -0400)
This PR fits in the ongoing work to improve deserializing from plain JSON objects.

The JSON deserialization engine no longer raises errors on missing attributes when a default value is available. Attributes may be missing because the JSON object come from a third-party API or
when loading serialized data from a previous version of the software. Default values include simple default values (`var x = 4`), lazy attributes and nullable types (which are set to `null`). This does not yet include `optional` attributes, more work would be needed.

The test/example can be activated when #2296 is fixed.

Pull-Request: #2302
Reviewed-by: Jean Privat <jean@pryen.org>
Reviewed-by: Alexandre Terrasa <alexandre@moz-code.org>

lib/json/serialization.nit
lib/serialization/serialization.nit
src/frontend/serialization_phase.nit
src/modelize/modelize_property.nit
tests/sav/niti/test_json_deserialization_plain_alt2.res
tests/sav/test_json_deserialization_plain.res
tests/sav/test_json_deserialization_plain_alt2.res
tests/test_json_deserialization_plain.nit

index e26b8ec..983f50b 100644 (file)
 # assert deserializer.errors.is_empty # If false, `obj` is invalid
 # print object
 # ~~~
+#
+# ### Missing attributes and default values
+#
+# When reading JSON, some attributes expected by Nit classes may be missing.
+# The JSON object may come from an external API using optional attributes or
+# from a previous version of your program without the attributes.
+# When an attribute is not found, the deserialization engine acts in one of three ways:
+#
+# 1. If the attribute has a default value or if it is annotated by `lazy`,
+#    the engine leave the attribute to the default value. No error is raised.
+# 2. If the static type of the attribute is nullable, the engine sets
+#    the attribute to `null`. No error is raised.
+# 3. Otherwise, the engine raises an error and does not set the attribute.
+#    The caller must check for `errors` and must not read from the attribute.
+#
+# ~~~nitish
+# import json::serialization
+#
+# class MyConfig
+#     serialize
+#
+#     var width: Int # Must be in JSON or an error is raised
+#     var height = 4
+#     var volume_level = 8 is lazy
+#     var player_name: nullable String
+#     var tmp_dir: nullable String = "/tmp" is lazy
+# end
+#
+# # ---
+# # JSON object with all expected attributes -> OK
+# var plain_json = """
+# {
+#     "width": 11,
+#     "height": 22,
+#     "volume_level": 33,
+#     "player_name": "Alice",
+#     "tmp_dir": null
+# }"""
+# var deserializer = new JsonDeserializer(plain_json)
+# var obj = new MyConfig.from_deserializer(deserializer)
+#
+# assert deserializer.errors.is_empty
+# assert obj.width == 11
+# assert obj.height == 22
+# assert obj.volume_level == 33
+# assert obj.player_name == "Alice"
+# assert obj.tmp_dir == null
+#
+# # ---
+# # JSON object missing optional attributes -> OK
+# plain_json = """
+# {
+#     "width": 11
+# }"""
+# deserializer = new JsonDeserializer(plain_json)
+# obj = new MyConfig.from_deserializer(deserializer)
+#
+# assert deserializer.errors.is_empty
+# assert obj.width == 11
+# assert obj.height == 4
+# assert obj.volume_level == 8
+# assert obj.player_name == null
+# assert obj.tmp_dir == "/tmp"
+#
+# # ---
+# # JSON object missing the mandatory attribute -> Error
+# plain_json = """
+# {
+#     "player_name": "Bob",
+# }"""
+# deserializer = new JsonDeserializer(plain_json)
+# obj = new MyConfig.from_deserializer(deserializer)
+#
+# # There's an error, `obj` is partial
+# assert deserializer.errors.length == 1
+#
+# # Still, we can access valid attributes
+# assert obj.player_name == "Bob"
+# ~~~
 module serialization
 
 import ::serialization::caching
@@ -295,13 +374,15 @@ class JsonDeserializer
                        if not root isa Error then
                                errors.add new Error("Deserialization Error: parsed JSON value is not an object.")
                        end
+                       deserialize_attribute_missing = false
                        return null
                end
 
                var current = path.last
 
                if not current.keys.has(name) then
-                       errors.add new Error("Deserialization Error: JSON object has not attribute '{name}'.")
+                       # Let the generated code / caller of `deserialize_attribute` raise the missing attribute error
+                       deserialize_attribute_missing = true
                        return null
                end
 
@@ -310,6 +391,8 @@ class JsonDeserializer
                attributes_path.add name
                var res = convert_object(value, static_type)
                attributes_path.pop
+
+               deserialize_attribute_missing = false
                return res
        end
 
index 5630b34..db02122 100644 (file)
@@ -100,9 +100,15 @@ abstract class Deserializer
        # The `static_type` can be used as last resort if the deserialized object
        # desn't have any metadata declaring the dynamic type.
        #
+       # Return the deserialized value or null on error, and set
+       # `deserialize_attribute_missing` to whether the attribute was missing.
+       #
        # Internal method to be implemented by the engines.
        fun deserialize_attribute(name: String, static_type: nullable String): nullable Object is abstract
 
+       # Was the attribute queried by the last call to `deserialize_attribute` missing?
+       var deserialize_attribute_missing = false
+
        # Register a newly allocated object (even if not completely built)
        #
        # Internal method called by objects in creation, to be implemented by the engines.
index b9cab9d..9eb01a4 100644 (file)
@@ -312,18 +312,29 @@ do
                                code.add """
        self.{{{name}}} = v.deserialize_attribute("{{{attribute.serialize_name}}}", "{{{type_name}}}")
 """
-                       else code.add """
+                       else
+                               code.add """
        var {{{name}}} = v.deserialize_attribute("{{{attribute.serialize_name}}}", "{{{type_name}}}")
-       if not {{{name}}} isa {{{type_name}}} then
-               # Check if it was a subjectent error
-               v.errors.add new AttributeTypeError(self, "{{{attribute.serialize_name}}}", {{{name}}}, "{{{type_name}}}")
+       if v.deserialize_attribute_missing then
+"""
+                               # What to do when an attribute is missing?
+                               if attribute.has_value then
+                                       # Leave it to the default value
+                               else if mtype isa MNullableType then
+                                       code.add """
+               self.{{{name}}} = null"""
+                               else code.add """
+               v.errors.add new Error("Deserialization Error: attribute `{class_name}::{{{name}}}` missing from JSON object")"""
 
-               # Clear subjacent error
+                               code.add """
+       else if not {{{name}}} isa {{{type_name}}} then
+               v.errors.add new AttributeTypeError(self, "{{{attribute.serialize_name}}}", {{{name}}}, "{{{type_name}}}")
                if v.keep_going == false then return
        else
                self.{{{name}}} = {{{name}}}
        end
 """
+                       end
                end
 
                code.add "end"
index 9465dbb..44d551d 100644 (file)
@@ -1158,8 +1158,8 @@ redef class AAttrPropdef
        # Is the node tagged optional?
        var is_optional = false
 
-       # Has the node a default value?
-       # Could be through `n_expr` or `n_block`
+       # Does the node have a default value?
+       # Could be through `n_expr`, `n_block` or `is_lazy`
        var has_value = false
 
        # The guard associated to a lazy attribute.
index 102d57b..a6ae660 100644 (file)
@@ -1,3 +1,3 @@
 Runtime error: Uninitialized attribute _s (alt/test_json_deserialization_plain_alt2.nit:27)
 # JSON: {"__class": "MyClass", "i": 123, "o": null}
-# Errors: 'Deserialization Error: JSON object has not attribute 's'.', 'Deserialization Error: Wrong type on `MyClass::s` expected `String`, got `null`', 'Deserialization Error: JSON object has not attribute 'f'.', 'Deserialization Error: Wrong type on `MyClass::f` expected `Float`, got `null`', 'Deserialization Error: JSON object has not attribute 'a'.', 'Deserialization Error: Wrong type on `MyClass::a` expected `Array[String]`, got `null`'
+# Errors: 'Deserialization Error: attribute `MyClass::s` missing from JSON object', 'Deserialization Error: attribute `MyClass::f` missing from JSON object', 'Deserialization Error: attribute `MyClass::a` missing from JSON object'
index 71dc9e7..62b3c82 100644 (file)
@@ -11,7 +11,6 @@
 # Nit: <MyClass i:123 s:hello f:123.456 a:[one, two] o:<null>>
 
 # JSON: {"__class": "MyClass", "i": 123, "s": "hello", "f": 123.456, "a": ["one", "two"]}
-# Errors: 'Deserialization Error: JSON object has not attribute 'o'.'
 # Nit: <MyClass i:123 s:hello f:123.456 a:[one, two] o:<null>>
 
 # JSON: {"__class": "MyClass", "i": 123, "s": "hello", "f": 123.456, "a": ["one", "two"], "o":
index 54e5a4d..4b53f0f 100644 (file)
@@ -1,3 +1,3 @@
 Runtime error: Uninitialized attribute _s (alt/test_json_deserialization_plain_alt2.nit:22)
 # JSON: {"__class": "MyClass", "i": 123, "o": null}
-# Errors: 'Deserialization Error: JSON object has not attribute 's'.', 'Deserialization Error: Wrong type on `MyClass::s` expected `String`, got `null`', 'Deserialization Error: JSON object has not attribute 'f'.', 'Deserialization Error: Wrong type on `MyClass::f` expected `Float`, got `null`', 'Deserialization Error: JSON object has not attribute 'a'.', 'Deserialization Error: Wrong type on `MyClass::a` expected `Array[String]`, got `null`'
+# Errors: 'Deserialization Error: attribute `MyClass::s` missing from JSON object', 'Deserialization Error: attribute `MyClass::f` missing from JSON object', 'Deserialization Error: attribute `MyClass::a` missing from JSON object'
index e577eda..7358c85 100644 (file)
@@ -49,7 +49,7 @@ tests.add """
 tests.add """
 {"__class": "MyClass", "i": 123, "s": "hello", "f": 123.456, "o": null, "a": ["one", "two"], "Some random attribute": 777}"""
 
-# Skipping `o` will cause an error but the attribute will be set to `null`
+# Skipping `o` will set the attribute to `null`
 tests.add """
 {"__class": "MyClass", "i": 123, "s": "hello", "f": 123.456, "a": ["one", "two"]}"""