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>
# 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
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
attributes_path.add name
var res = convert_object(value, static_type)
attributes_path.pop
+
+ deserialize_attribute_missing = false
return res
end
# 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.
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"
# 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.
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'
# 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":
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'
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"]}"""