diff --git a/lib/json/serialization.nit b/lib/json/serialization.nit index e26b8ec1d9..983f50b7c3 100644 --- a/lib/json/serialization.nit +++ b/lib/json/serialization.nit @@ -134,6 +134,85 @@ # 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 diff --git a/lib/serialization/serialization.nit b/lib/serialization/serialization.nit index 5630b34acd..db0212258a 100644 --- a/lib/serialization/serialization.nit +++ b/lib/serialization/serialization.nit @@ -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. diff --git a/src/frontend/serialization_phase.nit b/src/frontend/serialization_phase.nit index b9cab9d4a4..9eb01a4181 100644 --- a/src/frontend/serialization_phase.nit +++ b/src/frontend/serialization_phase.nit @@ -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" diff --git a/src/modelize/modelize_property.nit b/src/modelize/modelize_property.nit index 9465dbba41..44d551d02d 100644 --- a/src/modelize/modelize_property.nit +++ b/src/modelize/modelize_property.nit @@ -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. diff --git a/tests/sav/niti/test_json_deserialization_plain_alt2.res b/tests/sav/niti/test_json_deserialization_plain_alt2.res index 102d57b2dc..a6ae6607d3 100644 --- a/tests/sav/niti/test_json_deserialization_plain_alt2.res +++ b/tests/sav/niti/test_json_deserialization_plain_alt2.res @@ -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' diff --git a/tests/sav/test_json_deserialization_plain.res b/tests/sav/test_json_deserialization_plain.res index 71dc9e7a14..62b3c82090 100644 --- a/tests/sav/test_json_deserialization_plain.res +++ b/tests/sav/test_json_deserialization_plain.res @@ -11,7 +11,6 @@ # Nit: > # JSON: {"__class": "MyClass", "i": 123, "s": "hello", "f": 123.456, "a": ["one", "two"]} -# Errors: 'Deserialization Error: JSON object has not attribute 'o'.' # Nit: > # JSON: {"__class": "MyClass", "i": 123, "s": "hello", "f": 123.456, "a": ["one", "two"], "o": diff --git a/tests/sav/test_json_deserialization_plain_alt2.res b/tests/sav/test_json_deserialization_plain_alt2.res index 54e5a4da8d..4b53f0ffaf 100644 --- a/tests/sav/test_json_deserialization_plain_alt2.res +++ b/tests/sav/test_json_deserialization_plain_alt2.res @@ -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' diff --git a/tests/test_json_deserialization_plain.nit b/tests/test_json_deserialization_plain.nit index e577eda96f..7358c85db9 100644 --- a/tests/test_json_deserialization_plain.nit +++ b/tests/test_json_deserialization_plain.nit @@ -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"]}"""